From bad2312f18d68b17ed1ea1375542826b1528bb22 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 14:45:35 +0800 Subject: [PATCH 1/8] feat: support grouped grand totals by dimension Allow pivot grand totals to retain lower-level dimensions. Preserve existing behavior when the option is omitted. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../en/data-analysis/pivot-analysis-total.md | 3 +- .../zh/data-analysis/pivot-analysis-total.md | 3 +- .../data_analysis/pivot_table_dataAnalysis.md | 3 + .../data_analysis/pivot_table_dataAnalysis.md | 3 + docs/assets/option/en/table/pivotTable.md | 2 + docs/assets/option/zh/table/pivotTable.md | 2 + .../__tests__/pivotTable-analysis.test.ts | 111 ++++ packages/vtable/src/dataset/dataset.ts | 590 ++++++++++-------- packages/vtable/src/ts-types/new-data-set.ts | 2 + 9 files changed, 467 insertions(+), 252 deletions(-) diff --git a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md index a0fdb79b40..278ba495b2 100644 --- a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # Pivot Analysis - Subtotal Total -To summarize table data in pivot analysis, configure totals in dataConfig to set the total subtotal of the row and column dimensions.This example summary is always displayed to the bottom, and can also be displayed to the top through total.showGrandTotalsOnTop. [Configuration reference](../../option/PivotTable#dataConfig.totals) +Configure `dataConfig.totals` to display subtotals and grand totals for row and column dimensions. In this example, row totals retain the original behavior, while `grandTotalDimensions: ['Segment']` groups the column grand total by `Segment` and appends an overall subtotal containing all groups. Totals appear at the bottom or on the right by default; use `showGrandTotalsOnTop` to move them to the top or left. [Configuration reference](../../option/PivotTable#dataConfig.totals) ## Key Configurations @@ -178,6 +178,7 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['Region'], + grandTotalDimensions: ['Segment'], grandTotalLabel: 'Column Totals', subTotalLabel: 'Sub Totals' } diff --git a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md index 36cb888639..44791e0a3d 100644 --- a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # 透视分析——小计总计 -透视分析表格数据汇总,dataConfig 中配置 totals 来设置行列维度的小计总计。该示例汇总总显示到底部,也可以通过 total.showGrandTotalsOnTop 来显示到顶部。[配置参考](../../option/PivotTable#dataConfig.totals) +透视分析表格通过 `dataConfig.totals` 配置行列维度的小计和总计。本示例中,行方向保持原有的小计和总计效果;列方向通过 `grandTotalDimensions: ['Segment']`,让列总计在 `Segment` 维度下分组展示,并追加一个包含全部分组的整体小计。汇总默认显示在底部或右侧,也可以通过 `showGrandTotalsOnTop` 调整到顶部或左侧。[配置参考](../../option/PivotTable#dataConfig.totals) ## 关键配置 @@ -178,6 +178,7 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['Region'], + grandTotalDimensions: ['Segment'], grandTotalLabel: 'Column Totals', subTotalLabel: 'Sub Totals' } diff --git a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md index 682cfd9b1e..05858ce3d0 100644 --- a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md @@ -82,6 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], + grandTotalDimensions: ['city'], // Group the grand total by city and append an overall subtotal grandTotalLabel: 'row total', subTotalLabel: 'Subtotal', showGrandTotalsOnTop: true, //totals show on top @@ -99,6 +100,8 @@ dataConfig: { }, ``` +`grandTotalDimensions` retains lower-level dimensions in the grand total area. For example, when the row dimensions are `['province', 'city']`, setting `grandTotalDimensions: ['city']` on the row total displays a total for each city under "row total" and appends an overall "Subtotal" row. The same option is available for column totals. Only dimensions after the first dimension on the current axis are supported. When omitted, VTable keeps the original single grand total. + Online demo:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total #### Custom summary data diff --git a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md index 4d41ba4c14..1df878dea2 100644 --- a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md @@ -82,6 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], + grandTotalDimensions: ['city'], // 总计区域继续按 city 分组,并追加整体小计 grandTotalLabel: '行总计', subTotalLabel: '小计', showGrandTotalsOnTop: true, //汇总值显示在上 @@ -99,6 +100,8 @@ dataConfig: { }, ``` +`grandTotalDimensions` 用于在总计区域中保留下级维度。比如行维度为 `['province', 'city']` 时,将行总计配置为 `grandTotalDimensions: ['city']`,会在“行总计”下按城市分别显示汇总值,并追加一行整体“小计”。该配置也可用于列总计,只支持当前行或列方向首层之后的维度;不配置时仍显示原有的单一总计。 + 具体示例:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total #### 自定义汇总数据 diff --git a/docs/assets/option/en/table/pivotTable.md b/docs/assets/option/en/table/pivotTable.md index 43e8cd36e4..d23c35eac1 100644 --- a/docs/assets/option/en/table/pivotTable.md +++ b/docs/assets/option/en/table/pivotTable.md @@ -225,6 +225,8 @@ export interface Total { showSubTotals: boolean; // Subtotal summary dimension definition subTotalsDimensions?: string[]; + // Lower-level dimensions retained in the grand total area for grouped totals. Only dimensions after the first dimension on the current axis are supported. + grandTotalDimensions?: string[]; //Default 'total' grandTotalLabel?: string; //Default 'Subtotal' diff --git a/docs/assets/option/zh/table/pivotTable.md b/docs/assets/option/zh/table/pivotTable.md index 34b21b536b..a201cd5f0d 100644 --- a/docs/assets/option/zh/table/pivotTable.md +++ b/docs/assets/option/zh/table/pivotTable.md @@ -229,6 +229,8 @@ export interface Total { showSubTotals: boolean; // 小计汇总维度定义 subTotalsDimensions?: string[]; + // 总计中保留的下级维度,用于在总计区域内继续按维度分组;仅支持当前行/列方向首层之后的维度 + grandTotalDimensions?: string[]; // 默认'总计' grandTotalLabel?: string; // 默认'小计' diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index ffff4f483a..cd08c14f9c 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -378,3 +378,114 @@ describe('pivotTable-analysis init test', () => { pivotTable.release(); }); }); + +describe('pivotTable grand total grouped by lower-level dimension', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + + const pivotTable = new PivotTable(containerDom, { + rows: ['organization', 'type'], + columns: [], + indicators: ['balance'], + indicatorsAsCol: true, + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '商票', balance: 200 }, + { organization: '公司二', type: '银票', balance: 300 }, + { organization: '公司二', type: '信用证', balance: 400 } + ], + dataConfig: { + updateAggregationOnEditCell: true, + totals: { + row: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + + test('creates grouped grand total headers and values', () => { + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); + + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual( + expect.arrayContaining(['银票', '商票', '信用证', '小计']) + ); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(200); + expect(pivotTable.dataset.getAggregator(['合计', '信用证'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(1000); + }); + + test('keeps grouped grand total headers after sorting updates', () => { + pivotTable.updateSortRules([ + { + sortField: 'type', + sortType: VTable.TYPES.SortType.DESC + } + ]); + + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); + expect(grandTotalNode.children.map(node => node.value)).toEqual( + expect.arrayContaining(['银票', '商票', '信用证', '小计']) + ); + }); + + test('recalculates grouped and overall grand totals after editing', () => { + pivotTable.dataset.changeTreeNodeValue(['公司一', '银票'], [], 'balance', 150); + + const groupedGrandTotal = pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance'); + expect(groupedGrandTotal.children.map(child => child.value())).toEqual([150, 300]); + expect(groupedGrandTotal.value()).toBe(450); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(1050); + pivotTable.release(); + }); +}); + +describe('pivotTable column grand total grouped by lower-level dimension', () => { + test('creates grouped column grand total headers and values', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + + const pivotTable = new PivotTable(containerDom, { + rows: [], + columns: ['organization', 'type'], + indicators: ['balance'], + indicatorsAsCol: true, + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '商票', balance: 200 }, + { organization: '公司二', type: '银票', balance: 300 }, + { organization: '公司二', type: '信用证', balance: 400 } + ], + dataConfig: { + totals: { + column: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + + const grandTotalNode = pivotTable.dataset.colHeaderTree.find(node => node.value === '合计'); + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual( + expect.arrayContaining(['银票', '商票', '信用证', '小计']) + ); + expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1000); + pivotTable.release(); + }); +}); diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index a2f5c516e0..7a5fd81770 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -328,7 +328,9 @@ export class Dataset { (!this.indicatorsAsCol && this.columns.length === 0) || (this.indicatorsAsCol && this.rows.length === 0), this.rowGrandTotalLabel, - this.totals?.row?.showGrandTotalsOnTop ?? false + this.totals?.row?.showGrandTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions, + this.rowSubTotalLabel ); } else { this.rowHeaderTree = this.ArrToTree( @@ -342,7 +344,8 @@ export class Dataset { this.rowGrandTotalLabel, this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions ); } } @@ -376,7 +379,8 @@ export class Dataset { this.colGrandTotalLabel, this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, - this.totals?.column?.showSubTotalsOnLeft ?? false + this.totals?.column?.showSubTotalsOnLeft ?? false, + this.totals?.column?.grandTotalDimensions ); // } } @@ -1062,7 +1066,9 @@ export class Dataset { (!this.indicatorsAsCol && this.columns.length === 0) || (this.indicatorsAsCol && this.rows.length === 0), this.rowGrandTotalLabel, - this.totals?.row?.showGrandTotalsOnTop ?? false + this.totals?.row?.showGrandTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions, + this.rowSubTotalLabel ); } else { this.rowHeaderTree = this.ArrToTree( @@ -1076,7 +1082,8 @@ export class Dataset { this.rowGrandTotalLabel, this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.totals?.row?.grandTotalDimensions ); } } @@ -1093,7 +1100,8 @@ export class Dataset { this.colGrandTotalLabel, this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, - this.totals?.column?.showSubTotalsOnLeft ?? false + this.totals?.column?.showSubTotalsOnLeft ?? false, + this.totals?.column?.grandTotalDimensions ); } // this.rowKeysPath_FULL = this.TreeToArr( @@ -1653,63 +1661,73 @@ export class Dataset { } } } - if (that.totals?.column?.showGrandTotals || this.rows.length === 0) { - const flatColTotalKey = that.colGrandTotalLabel; - if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { - // 利用汇总数据替换 - this.tree[flatRowKey][flatColTotalKey] = this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]; - return; - } - if (!this.tree[flatRowKey][flatColTotalKey]) { - this.tree[flatRowKey][flatColTotalKey] = []; - } - const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; - for (let i = 0; i < toComputeIndicatorKeys.length; i++) { - if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { - const calculatedFieldRule = this.calculatedFieldRules?.find(rule => rule.key === toComputeIndicatorKeys[i]); - if (!this.tree[flatRowKey]?.[flatColTotalKey]?.[i]) { - this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ - key: toComputeIndicatorKeys[i], - field: toComputeIndicatorKeys[i], - isRecord: true, - formatFun: ( - this.indicators?.find((indicator: string | IIndicator) => { - if (typeof indicator !== 'string') { - return indicator.indicatorKey === toComputeIndicatorKeys[i]; - } - return false; - }) as IIndicator - )?.format, - calculateFun: calculatedFieldRule?.calculateFun, - dependAggregators: this.tree[flatRowKey][flatColTotalKey], - dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys - }); - } - if (flatColTotalKey !== flatColKey) { - this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); - } - } else { - if (!this.tree[flatRowKey][flatColTotalKey][i]) { - const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); - this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[ - aggRule?.aggregationType ?? AggregationType.SUM - ]({ - key: toComputeIndicatorKeys[i], - field: aggRule?.field ?? toComputeIndicatorKeys[i], - formatFun: - aggRule?.formatFun ?? - ( + if ((that.totals?.column?.showGrandTotals || this.rows.length === 0) && colKey[0] !== that.colGrandTotalLabel) { + const flatColTotalKeys = this.getGrandTotalKeys( + colKey, + that.columns, + that.totals?.column?.grandTotalDimensions, + that.colGrandTotalLabel, + that.colSubTotalLabel + ); + for (const flatColTotalKey of flatColTotalKeys) { + if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { + // 利用汇总数据替换 + this.tree[flatRowKey][flatColTotalKey] = this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]; + continue; + } + if (!this.tree[flatRowKey][flatColTotalKey]) { + this.tree[flatRowKey][flatColTotalKey] = []; + } + const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; + for (let i = 0; i < toComputeIndicatorKeys.length; i++) { + if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { + const calculatedFieldRule = this.calculatedFieldRules?.find( + rule => rule.key === toComputeIndicatorKeys[i] + ); + if (!this.tree[flatRowKey]?.[flatColTotalKey]?.[i]) { + this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ + key: toComputeIndicatorKeys[i], + field: toComputeIndicatorKeys[i], + isRecord: true, + formatFun: ( this.indicators?.find((indicator: string | IIndicator) => { if (typeof indicator !== 'string') { return indicator.indicatorKey === toComputeIndicatorKeys[i]; } return false; }) as IIndicator - )?.format - }); - } - if (flatColTotalKey !== flatColKey) { - this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + )?.format, + calculateFun: calculatedFieldRule?.calculateFun, + dependAggregators: this.tree[flatRowKey][flatColTotalKey], + dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys + }); + } + if (flatColTotalKey !== flatColKey) { + this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + } + } else { + if (!this.tree[flatRowKey][flatColTotalKey][i]) { + const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); + this.tree[flatRowKey][flatColTotalKey][i] = new registeredAggregators[ + aggRule?.aggregationType ?? AggregationType.SUM + ]({ + key: toComputeIndicatorKeys[i], + field: aggRule?.field ?? toComputeIndicatorKeys[i], + formatFun: + aggRule?.formatFun ?? + ( + this.indicators?.find((indicator: string | IIndicator) => { + if (typeof indicator !== 'string') { + return indicator.indicatorKey === toComputeIndicatorKeys[i]; + } + return false; + }) as IIndicator + )?.format + }); + } + if (flatColTotalKey !== flatColKey) { + this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + } } } } @@ -1815,60 +1833,71 @@ export class Dataset { } } - if (that.totals?.row?.showGrandTotals || this.columns.length === 0) { - const flatRowTotalKey = that.rowGrandTotalLabel; - if (!this.tree[flatRowTotalKey]) { - this.tree[flatRowTotalKey] = {}; - rowTotalKeys.push(flatRowTotalKey); - } - if (!this.tree[flatRowTotalKey][flatColKey]) { - this.tree[flatRowTotalKey][flatColKey] = []; - } - const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; - for (let i = 0; i < toComputeIndicatorKeys.length; i++) { - if (!this.tree[flatRowTotalKey][flatColKey][i]) { - if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { - const calculatedFieldRule = this.calculatedFieldRules?.find( - rule => rule.key === toComputeIndicatorKeys[i] - ); - this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ - key: toComputeIndicatorKeys[i], - field: toComputeIndicatorKeys[i], - isRecord: true, - formatFun: ( - this.indicators?.find((indicator: string | IIndicator) => { - if (typeof indicator !== 'string') { - return indicator.indicatorKey === toComputeIndicatorKeys[i]; - } - return false; - }) as IIndicator - )?.format, - calculateFun: calculatedFieldRule?.calculateFun, - dependAggregators: this.tree[flatRowTotalKey][flatColKey], - dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys - }); - } else { - const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); - this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[ - aggRule?.aggregationType ?? AggregationType.SUM - ]({ - key: toComputeIndicatorKeys[i], - field: aggRule?.field ?? toComputeIndicatorKeys[i], - formatFun: - aggRule?.formatFun ?? - ( + if ( + (that.totals?.row?.showGrandTotals || this.columns.length === 0) && + rowKey[0] !== that.rowGrandTotalLabel + ) { + const flatRowTotalKeys = this.getGrandTotalKeys( + rowKey, + that.rows, + that.totals?.row?.grandTotalDimensions, + that.rowGrandTotalLabel, + that.rowSubTotalLabel + ); + for (const flatRowTotalKey of flatRowTotalKeys) { + if (!this.tree[flatRowTotalKey]) { + this.tree[flatRowTotalKey] = {}; + rowTotalKeys.push(flatRowTotalKey); + } + if (!this.tree[flatRowTotalKey][flatColKey]) { + this.tree[flatRowTotalKey][flatColKey] = []; + } + const toComputeIndicatorKeys = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys; + for (let i = 0; i < toComputeIndicatorKeys.length; i++) { + if (!this.tree[flatRowTotalKey][flatColKey][i]) { + if (this.calculatedFiledKeys.indexOf(toComputeIndicatorKeys[i]) >= 0) { + const calculatedFieldRule = this.calculatedFieldRules?.find( + rule => rule.key === toComputeIndicatorKeys[i] + ); + this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[AggregationType.RECALCULATE]({ + key: toComputeIndicatorKeys[i], + field: toComputeIndicatorKeys[i], + isRecord: true, + formatFun: ( this.indicators?.find((indicator: string | IIndicator) => { if (typeof indicator !== 'string') { return indicator.indicatorKey === toComputeIndicatorKeys[i]; } return false; }) as IIndicator - )?.format - }); + )?.format, + calculateFun: calculatedFieldRule?.calculateFun, + dependAggregators: this.tree[flatRowTotalKey][flatColKey], + dependIndicatorKeys: calculatedFieldRule?.dependIndicatorKeys + }); + } else { + const aggRule = this.getAggregatorRule(toComputeIndicatorKeys[i]); + this.tree[flatRowTotalKey][flatColKey][i] = new registeredAggregators[ + aggRule?.aggregationType ?? AggregationType.SUM + ]({ + key: toComputeIndicatorKeys[i], + field: aggRule?.field ?? toComputeIndicatorKeys[i], + formatFun: + aggRule?.formatFun ?? + ( + this.indicators?.find((indicator: string | IIndicator) => { + if (typeof indicator !== 'string') { + return indicator.indicatorKey === toComputeIndicatorKeys[i]; + } + return false; + }) as IIndicator + )?.format + }); + } + } + if (flatRowTotalKey !== flatRowKey) { + this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } - } - if (flatRowTotalKey !== flatRowKey) { - this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } } } @@ -1928,13 +1957,40 @@ export class Dataset { * @param arr * @returns */ + private getGrandTotalDimensionIndexes(dimensions: string[], grandTotalDimensions?: string[]) { + if (!grandTotalDimensions?.length) { + return []; + } + return grandTotalDimensions + .map(dimension => dimensions.indexOf(dimension)) + .filter((index, position, indexes) => index > 0 && indexes.indexOf(index) === position) + .sort((a, b) => a - b); + } + + private getGrandTotalKeys( + dimensionKey: string[], + dimensions: string[], + grandTotalDimensions: string[] | undefined, + grandTotalLabel: string, + subTotalLabel: string + ) { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + if (!dimensionIndexes.length) { + return [grandTotalLabel]; + } + const groupedKey = [grandTotalLabel, ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex])]; + return [join(groupedKey, this.stringJoinChar), join([grandTotalLabel, subTotalLabel], this.stringJoinChar)]; + } + private ArrToTree1( arr: string[][], rows: string[], indicators: (string | IIndicator)[] | undefined, isGrandTotal: boolean, grandTotalLabel: string, - showGrandTotalsOnTop: boolean + showGrandTotalsOnTop: boolean, + grandTotalDimensions?: string[], + subTotalLabel?: string ) { /** * @@ -1991,9 +2047,64 @@ export class Dataset { }); } + const createIndicatorChildren = () => + indicators?.map(indicator => { + if (typeof indicator === 'string') { + return { + indicatorKey: indicator, + value: indicator + }; + } + return { + indicatorKey: indicator.indicatorKey, + value: indicator.title + }; + }) ?? []; + arr.forEach(item => addList(item, false)); if (isGrandTotal) { - addList([grandTotalLabel], isGrandTotal); + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions); + if (!grandTotalDimensionIndexes.length) { + addList([grandTotalLabel], isGrandTotal); + } else { + const grandTotalNode: any = { + value: grandTotalLabel, + dimensionKey: rows[0], + children: [], + role: 'grand-total' + }; + const grandTotalMap = new Map(); + arr.forEach(list => { + let parent = grandTotalNode; + const path = [grandTotalLabel]; + grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { + const value = list[dimensionIndex]; + path.push(value); + const flatKey = join(path, concatStr); + let item = grandTotalMap.get(flatKey); + if (!item) { + item = { + value, + dimensionKey: rows[dimensionIndex], + children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] + }; + grandTotalMap.set(flatKey, item); + parent.children.push(item); + } + parent = item; + }); + }); + grandTotalNode.children.push({ + value: subTotalLabel, + dimensionKey: rows[grandTotalDimensionIndexes[0]], + children: createIndicatorChildren() + }); + if (showGrandTotalsOnTop) { + result.unshift(grandTotalNode); + } else { + result.push(grandTotalNode); + } + } } return result; } @@ -2012,7 +2123,8 @@ export class Dataset { grandTotalLabel: string, subTotalLabel: string, showGrandTotalsOnTop: boolean, - showSubTotalsOnTop: boolean + showSubTotalsOnTop: boolean, + grandTotalDimensions?: string[] ) { /** * @@ -2120,25 +2232,65 @@ export class Dataset { } //最后将总计的节点加上 if (isGrandTotal && arr?.length) { + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions); + const createIndicatorChildren = () => + indicators?.map(indicator => { + if (typeof indicator === 'string') { + return { + indicatorKey: indicator, + value: indicator + }; + } + return { + indicatorKey: indicator.indicatorKey, + value: indicator.title + }; + }) ?? []; const node: { value: string; dimensionKey: string; children: any[]; levelSpan: number; role: string } = { value: grandTotalLabel, // getId(item?.id, 1), dimensionKey: rows[0], - levelSpan: subTotalFlags.length, - children: - indicators?.map(indicator => { - if (typeof indicator === 'string') { - return { - indicatorKey: indicator, - value: indicator - }; - } - return { - indicatorKey: indicator.indicatorKey, - value: indicator.title - }; - }) ?? [], + levelSpan: grandTotalDimensionIndexes[0] ?? subTotalFlags.length, + children: [], role: 'grand-total' }; + if (grandTotalDimensionIndexes.length) { + const grandTotalMap = new Map(); + arr.forEach(list => { + let parent = node; + const path = [grandTotalLabel]; + grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { + const value = list[dimensionIndex]; + path.push(value); + const flatKey = join(path, concatStr); + let item = grandTotalMap.get(flatKey); + if (!item) { + const nextDimensionIndex = grandTotalDimensionIndexes[index + 1] ?? rows.length; + item = { + value, + dimensionKey: rows[dimensionIndex], + levelSpan: nextDimensionIndex - dimensionIndex, + children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] + }; + grandTotalMap.set(flatKey, item); + parent.children.push(item); + } + parent = item; + }); + }); + const totalChild = { + value: subTotalLabel, + dimensionKey: rows[grandTotalDimensionIndexes[0]], + levelSpan: rows.length - grandTotalDimensionIndexes[0], + children: createIndicatorChildren() + }; + if (showSubTotalsOnTop) { + node.children.unshift(totalChild); + } else { + node.children.push(totalChild); + } + } else { + node.children = createIndicatorChildren(); + } if (showGrandTotalsOnTop) { result.unshift(node); } else { @@ -2328,15 +2480,15 @@ export class Dataset { //#region 参数处理与初始化 // 将输入参数转换为字符串格式,用于在树中查找 - const flatRowKey = Array.isArray(rowKey) ? rowKey.join(this.stringJoinChar) : rowKey; - const flatColKey = Array.isArray(colKey) ? colKey.join(this.stringJoinChar) : colKey; + const flatRowKey = Array.isArray(rowKey) ? join(rowKey, this.stringJoinChar) : rowKey; + const flatColKey = Array.isArray(colKey) ? join(colKey, this.stringJoinChar) : colKey; const indicatorIndex = this.indicatorKeysIncludeCalculatedFieldDependIndicatorKeys.indexOf(indicator); // 找出受影响的小计和总计 const aggregatorsToReset: Set = new Set(); // 存储所有需要重置的聚合器 const subTotalAggregators: Set = new Set(); // 用于标记哪些聚合器是小计 - const rowKeyParts = flatRowKey.split(this.stringJoinChar); // 拆分行键以便构建小计键 - const colKeyParts = flatColKey.split(this.stringJoinChar); // 拆分列键以便构建小计键 + const rowKeyParts = Array.isArray(rowKey) ? rowKey.slice() : rowKey.split(this.stringJoinChar); + const colKeyParts = Array.isArray(colKey) ? colKey.slice() : colKey.split(this.stringJoinChar); //#endregion //#region 辅助函数定义 @@ -2360,8 +2512,10 @@ export class Dataset { }; //#endregion - //#region 处理行维度的小计和总计 - // 1. 找出受影响的行小计 + const rowAggregateKeys: string[] = []; + const colAggregateKeys: string[] = []; + + // 找出受影响的行小计 if (this.totals?.row?.subTotalsDimensions && this.totals.row.showSubTotals !== false) { for (let i = 0; i < this.totals.row.subTotalsDimensions.length; i++) { const dimension = this.totals.row.subTotalsDimensions[i]; @@ -2374,146 +2528,82 @@ export class Dataset { rowSubTotalKeyParts.push(this.rowSubTotalLabel); // 添加小计标识 } const flatRowSubTotalKey = rowSubTotalKeyParts.join(this.stringJoinChar); - - // 添加行小计与当前列的交叉点 - if (this.tree[flatRowSubTotalKey]?.[flatColKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowSubTotalKey][flatColKey][indicatorIndex], - flatRowSubTotalKey, - flatColKey - ); - } - - // 添加行小计与列小计的交叉点 - if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { - for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { - const colDimension = this.totals.column.subTotalsDimensions[j]; - const colDimensionIndex = this.columns.indexOf(colDimension); - - if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { - const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); - colSubTotalKeyParts.push(this.colSubTotalLabel); - const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); - - if (this.tree[flatRowSubTotalKey]?.[flatColSubTotalKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowSubTotalKey][flatColSubTotalKey][indicatorIndex], - flatRowSubTotalKey, - flatColSubTotalKey - ); - } - } - } - } - - // 添加行小计与列总计的交叉点 - if (this.totals?.column?.showGrandTotals) { - if (this.tree[flatRowSubTotalKey]?.[this.colGrandTotalLabel]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowSubTotalKey][this.colGrandTotalLabel][indicatorIndex], - flatRowSubTotalKey, - this.colGrandTotalLabel - ); - } + if (rowAggregateKeys.indexOf(flatRowSubTotalKey) === -1) { + rowAggregateKeys.push(flatRowSubTotalKey); } } } } - // 处理行总计 if (this.totals?.row?.showGrandTotals) { - // 添加行总计与当前列的交叉点 - if (this.tree[this.rowGrandTotalLabel]?.[flatColKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[this.rowGrandTotalLabel][flatColKey][indicatorIndex], - this.rowGrandTotalLabel, - flatColKey - ); - - // 添加行总计与列小计的交叉点 - if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { - for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { - const colDimension = this.totals.column.subTotalsDimensions[j]; - const colDimensionIndex = this.columns.indexOf(colDimension); - - if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { - const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); - colSubTotalKeyParts.push(this.colSubTotalLabel); - const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); - - if (this.tree[this.rowGrandTotalLabel]?.[flatColSubTotalKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[this.rowGrandTotalLabel][flatColSubTotalKey][indicatorIndex], - this.rowGrandTotalLabel, - flatColSubTotalKey - ); - } - } - } - } - - // 添加行总计与列总计的交叉点(表格右下角的总计单元格) - if (this.totals?.column?.showGrandTotals) { - if (this.tree[this.rowGrandTotalLabel]?.[this.colGrandTotalLabel]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[this.rowGrandTotalLabel][this.colGrandTotalLabel][indicatorIndex], - this.rowGrandTotalLabel, - this.colGrandTotalLabel - ); - } + const rowGrandTotalKeys = this.getGrandTotalKeys( + rowKeyParts, + this.rows, + this.totals.row.grandTotalDimensions, + this.rowGrandTotalLabel, + this.rowSubTotalLabel + ); + for (const rowGrandTotalKey of rowGrandTotalKeys) { + if (rowAggregateKeys.indexOf(rowGrandTotalKey) === -1) { + rowAggregateKeys.push(rowGrandTotalKey); } } - //#endregion + } - //#region 处理列维度的小计和总计 - // 2. 找出受影响的列小计 - if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { - for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { - const colDimension = this.totals.column.subTotalsDimensions[j]; - const colDimensionIndex = this.columns.indexOf(colDimension); - - if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { - // 构建列小计的键 - const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); - colSubTotalKeyParts.push(this.colSubTotalLabel); - const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); - - // 添加当前行与列小计的交叉点 - if (this.tree[flatRowKey]?.[flatColSubTotalKey]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowKey][flatColSubTotalKey][indicatorIndex], - flatRowKey, - flatColSubTotalKey - ); - } + // 找出受影响的列小计 + if (this.totals?.column?.subTotalsDimensions && this.totals.column.showSubTotals !== false) { + for (let j = 0; j < this.totals.column.subTotalsDimensions.length; j++) { + const colDimension = this.totals.column.subTotalsDimensions[j]; + const colDimensionIndex = this.columns.indexOf(colDimension); + if (colDimensionIndex >= 0 && colDimensionIndex < colKeyParts.length) { + const colSubTotalKeyParts = colKeyParts.slice(0, colDimensionIndex + 1); + colSubTotalKeyParts.push(this.colSubTotalLabel); + const flatColSubTotalKey = colSubTotalKeyParts.join(this.stringJoinChar); + if (colAggregateKeys.indexOf(flatColSubTotalKey) === -1) { + colAggregateKeys.push(flatColSubTotalKey); } } } + } - // 处理列总计 - if (this.totals?.column?.showGrandTotals) { - // 添加当前行与列总计的交叉点 - if (this.tree[flatRowKey]?.[this.colGrandTotalLabel]?.[indicatorIndex]) { - addAggregatorToResetSet( - this.tree[flatRowKey][this.colGrandTotalLabel][indicatorIndex], - flatRowKey, - this.colGrandTotalLabel - ); + if (this.totals?.column?.showGrandTotals) { + const colGrandTotalKeys = this.getGrandTotalKeys( + colKeyParts, + this.columns, + this.totals.column.grandTotalDimensions, + this.colGrandTotalLabel, + this.colSubTotalLabel + ); + for (const colGrandTotalKey of colGrandTotalKeys) { + if (colAggregateKeys.indexOf(colGrandTotalKey) === -1) { + colAggregateKeys.push(colGrandTotalKey); } } - //#endregion + } - //#region 重新计算受影响的聚合器 - // 重置并重新计算受影响的聚合器 - aggregatorsToReset.forEach(agg => { - // 只对行小计和列小计添加新的aggregator(直接相关的小计) - if (newCellAggregator && subTotalAggregators.has(agg)) { - agg.push(newCellAggregator); // 将新的单元格聚合器添加到小计聚合器中 - } - agg.recalculate(); // 重新计算聚合值 + // 先重算直接依赖当前单元格的行/列汇总,再重算行列汇总交叉点。 + rowAggregateKeys.forEach(rowAggregateKey => { + addAggregatorToResetSet(this.tree[rowAggregateKey]?.[flatColKey]?.[indicatorIndex], rowAggregateKey, flatColKey); + }); + colAggregateKeys.forEach(colAggregateKey => { + addAggregatorToResetSet(this.tree[flatRowKey]?.[colAggregateKey]?.[indicatorIndex], flatRowKey, colAggregateKey); + }); + rowAggregateKeys.forEach(rowAggregateKey => { + colAggregateKeys.forEach(colAggregateKey => { + addAggregatorToResetSet( + this.tree[rowAggregateKey]?.[colAggregateKey]?.[indicatorIndex], + rowAggregateKey, + colAggregateKey + ); }); - //#endregion - } + }); + + aggregatorsToReset.forEach(agg => { + if (newCellAggregator && subTotalAggregators.has(agg)) { + agg.push(newCellAggregator); + } + agg.recalculate(); + }); } changeRecordFieldValue(fieldName: string, oldValue: string | number, value: string | number) { diff --git a/packages/vtable/src/ts-types/new-data-set.ts b/packages/vtable/src/ts-types/new-data-set.ts index 22332c25c5..4b135291ef 100644 --- a/packages/vtable/src/ts-types/new-data-set.ts +++ b/packages/vtable/src/ts-types/new-data-set.ts @@ -48,6 +48,8 @@ export interface Total { // calcSubTotals?: CalcTotals; /** 小计汇总维度定义 */ subTotalsDimensions?: string[]; + /** 总计中保留的下级维度,用于按维度分组显示总计 */ + grandTotalDimensions?: string[]; /** 汇总节点显示名称 默认'总计' */ grandTotalLabel?: string; /** 汇总节点显示名称 默认'小计' */ From cb9432da2a1a1feef3fc5006c0c43edc13904d06 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 16:01:23 +0800 Subject: [PATCH 2/8] fix: address grouped grand total review issues Isolate grouped total keys from display labels. Cover edge cases introduced by grandTotalDimensions. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../en/data-analysis/pivot-analysis-total.md | 2 +- .../zh/data-analysis/pivot-analysis-total.md | 2 +- .../data_analysis/pivot_table_dataAnalysis.md | 4 +- .../data_analysis/pivot_table_dataAnalysis.md | 4 +- .../__tests__/pivotTable-analysis.test.ts | 175 ++++++++++++++++ packages/vtable/src/PivotChart.ts | 12 +- packages/vtable/src/PivotTable.ts | 56 ++--- packages/vtable/src/dataset/dataset.ts | 191 ++++++++++++++---- .../vtable/src/layout/pivot-header-layout.ts | 4 + .../vtable/src/tools/get-data-path/index.ts | 4 +- packages/vtable/src/ts-types/common.ts | 2 + 11 files changed, 373 insertions(+), 83 deletions(-) diff --git a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md index 278ba495b2..86cb588a8a 100644 --- a/docs/assets/demo/en/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/en/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # Pivot Analysis - Subtotal Total -Configure `dataConfig.totals` to display subtotals and grand totals for row and column dimensions. In this example, row totals retain the original behavior, while `grandTotalDimensions: ['Segment']` groups the column grand total by `Segment` and appends an overall subtotal containing all groups. Totals appear at the bottom or on the right by default; use `showGrandTotalsOnTop` to move them to the top or left. [Configuration reference](../../option/PivotTable#dataConfig.totals) +Configure `dataConfig.totals` to display subtotals and grand totals for row and column dimensions. In this example, row totals retain the original behavior, while `grandTotalDimensions: ['Segment']` groups the column grand total by `Segment` and appends an overall subtotal containing all groups. Row grand totals appear at the bottom by default and can be moved to the top with `showGrandTotalsOnTop`; column grand totals appear on the right and can be moved to the left with `showGrandTotalsOnLeft`. [Configuration reference](../../option/PivotTable#dataConfig.totals) ## Key Configurations diff --git a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md index 44791e0a3d..8c3c1b88b4 100644 --- a/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md +++ b/docs/assets/demo/zh/data-analysis/pivot-analysis-total.md @@ -9,7 +9,7 @@ option: PivotTable#dataConfig.totals # 透视分析——小计总计 -透视分析表格通过 `dataConfig.totals` 配置行列维度的小计和总计。本示例中,行方向保持原有的小计和总计效果;列方向通过 `grandTotalDimensions: ['Segment']`,让列总计在 `Segment` 维度下分组展示,并追加一个包含全部分组的整体小计。汇总默认显示在底部或右侧,也可以通过 `showGrandTotalsOnTop` 调整到顶部或左侧。[配置参考](../../option/PivotTable#dataConfig.totals) +透视分析表格通过 `dataConfig.totals` 配置行列维度的小计和总计。本示例中,行方向保持原有的小计和总计效果;列方向通过 `grandTotalDimensions: ['Segment']`,让列总计在 `Segment` 维度下分组展示,并追加一个包含全部分组的整体小计。行总计默认显示在底部,可通过 `showGrandTotalsOnTop` 调整到顶部;列总计默认显示在右侧,可通过 `showGrandTotalsOnLeft` 调整到左侧。[配置参考](../../option/PivotTable#dataConfig.totals) ## 关键配置 diff --git a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md index 05858ce3d0..ca00ea1703 100644 --- a/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/en/data_analysis/pivot_table_dataAnalysis.md @@ -82,7 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], - grandTotalDimensions: ['city'], // Group the grand total by city and append an overall subtotal + grandTotalDimensions: ['province'], // Group the grand total by province and append an overall subtotal grandTotalLabel: 'row total', subTotalLabel: 'Subtotal', showGrandTotalsOnTop: true, //totals show on top @@ -100,7 +100,7 @@ dataConfig: { }, ``` -`grandTotalDimensions` retains lower-level dimensions in the grand total area. For example, when the row dimensions are `['province', 'city']`, setting `grandTotalDimensions: ['city']` on the row total displays a total for each city under "row total" and appends an overall "Subtotal" row. The same option is available for column totals. Only dimensions after the first dimension on the current axis are supported. When omitted, VTable keeps the original single grand total. +`grandTotalDimensions` retains lower-level dimensions in the grand total area. For example, with the row dimensions `['region', 'province']` shown above, setting `grandTotalDimensions: ['province']` displays a total for each province under "row total" and appends an overall "Subtotal" row. The same option is available for column totals. Only dimensions after the first dimension on the current axis are supported. When omitted, VTable keeps the original single grand total. Online demo:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total diff --git a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md index 1df878dea2..e15a652dfc 100644 --- a/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md +++ b/docs/assets/guide/zh/data_analysis/pivot_table_dataAnalysis.md @@ -82,7 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], - grandTotalDimensions: ['city'], // 总计区域继续按 city 分组,并追加整体小计 + grandTotalDimensions: ['province'], // 总计区域继续按 province 分组,并追加整体小计 grandTotalLabel: '行总计', subTotalLabel: '小计', showGrandTotalsOnTop: true, //汇总值显示在上 @@ -100,7 +100,7 @@ dataConfig: { }, ``` -`grandTotalDimensions` 用于在总计区域中保留下级维度。比如行维度为 `['province', 'city']` 时,将行总计配置为 `grandTotalDimensions: ['city']`,会在“行总计”下按城市分别显示汇总值,并追加一行整体“小计”。该配置也可用于列总计,只支持当前行或列方向首层之后的维度;不配置时仍显示原有的单一总计。 +`grandTotalDimensions` 用于在总计区域中保留下级维度。比如上述行维度为 `['region', 'province']` 时,将行总计配置为 `grandTotalDimensions: ['province']`,会在“行总计”下按省份分别显示汇总值,并追加一行整体“小计”。该配置也可用于列总计,只支持当前行或列方向首层之后的维度;不配置时仍显示原有的单一总计。 具体示例:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index cd08c14f9c..23ab65c571 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -467,6 +467,7 @@ describe('pivotTable column grand total grouped by lower-level dimension', () => { organization: '公司二', type: '信用证', balance: 400 } ], dataConfig: { + updateAggregationOnEditCell: true, totals: { column: { showGrandTotals: true, @@ -486,6 +487,180 @@ describe('pivotTable column grand total grouped by lower-level dimension', () => ); expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(400); expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1000); + + pivotTable.dataset.changeTreeNodeValue([], ['公司一', '银票'], 'balance', 150); + expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(450); + expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1050); + pivotTable.release(); + }); +}); + +describe('pivotTable grouped grand total edge cases', () => { + function createPivotTable( + records: any[], + options: { + rows?: string[]; + rowHierarchyType?: 'grid' | 'tree'; + grandTotalDimensions?: string[]; + showSubTotalsOnTop?: boolean; + aggregationRules?: any[]; + } = {} + ) { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + return new PivotTable(containerDom, { + rows: options.rows ?? ['organization', 'type'], + columns: [], + indicators: ['balance'], + indicatorsAsCol: true, + rowHierarchyType: options.rowHierarchyType, + records, + dataConfig: { + aggregationRules: options.aggregationRules, + totals: { + row: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: options.grandTotalDimensions ?? ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计', + showSubTotalsOnTop: options.showSubTotalsOnTop + } + } + } + }); + } + + test('keeps records whose dimension values match total labels separate from grouped totals', () => { + const pivotTable = createPivotTable([ + { organization: '合计', type: '小计', balance: 100 }, + { organization: '公司二', type: '小计', balance: 300 } + ]); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + const groupedNode = grandTotalNode.children[0]; + const overallNode = grandTotalNode.children[1]; + + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(100); + expect( + pivotTable.dataset.getAggregator([grandTotalNode.dataValue, groupedNode.dataValue], [], 'balance').value() + ).toBe(400); + expect( + pivotTable.dataset.getAggregator([grandTotalNode.dataValue, overallNode.dataValue], [], 'balance').value() + ).toBe(400); + pivotTable.release(); + }); + + test('uses active dimension indexes when an intermediate dimension has no values', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + { + rows: ['organization', 'unused', 'type'] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); + pivotTable.release(); + }); + + test('keeps column values matching the grand total label separate from grouped totals', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: [], + columns: ['organization', 'type'], + indicators: ['balance'], + indicatorsAsCol: true, + records: [ + { organization: '合计', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + column: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + const grandTotalNode = pivotTable.dataset.colHeaderTree.find(node => node.role === 'grand-total'); + + expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(100); + expect( + pivotTable.dataset + .getAggregator([], [grandTotalNode.dataValue, grandTotalNode.children[0].dataValue], 'balance') + .value() + ).toBe(400); + pivotTable.release(); + }); + + test('passes the custom aggregation function to grouped and overall totals', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + { + aggregationRules: [ + { + indicatorKey: 'balance', + field: 'balance', + aggregationType: VTable.TYPES.AggregationType.CUSTOM, + aggregationFun: (values: number[]) => values.reduce((sum, value) => sum + value, 0) / values.length + } + ] + } + ); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(200); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(200); + pivotTable.release(); + }); + + test('keeps custom overall total records effective with grouped grand totals', () => { + const pivotTable = createPivotTable([ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 }, + { balance: 999 } + ]); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(999); + pivotTable.release(); + }); + + test('orders tree grouped totals by retained dimensions and honors subtotal placement', () => { + const pivotTable = createPivotTable( + [ + { organization: 'A', type: 'A', balance: 100 }, + { organization: 'A', type: 'B', balance: 200 }, + { organization: 'B', type: 'A', balance: 300 } + ], + { + rowHierarchyType: 'tree', + showSubTotalsOnTop: true + } + ); + pivotTable.updateSortRules([ + { sortField: 'organization', sortType: VTable.TYPES.SortType.ASC }, + { sortField: 'type', sortType: VTable.TYPES.SortType.DESC } + ]); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['小计', 'B', 'A']); + expect(pivotTable.dataset.getAggregator(['合计', 'B'], [], 'balance').value()).toBe(200); pivotTable.release(); }); }); diff --git a/packages/vtable/src/PivotChart.ts b/packages/vtable/src/PivotChart.ts index dce0beee77..feffb0cd0b 100644 --- a/packages/vtable/src/PivotChart.ts +++ b/packages/vtable/src/PivotChart.ts @@ -674,7 +674,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any, index: number) => { if (rowPath.indicatorKey) { @@ -683,7 +683,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -740,7 +740,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any, index: number) => { if (rowPath.indicatorKey) { @@ -749,7 +749,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -791,7 +791,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any, index: number) => { if (rowPath.indicatorKey) { @@ -800,7 +800,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, diff --git a/packages/vtable/src/PivotTable.ts b/packages/vtable/src/PivotTable.ts index fcfbb1a5f4..09b28c6efa 100644 --- a/packages/vtable/src/PivotTable.ts +++ b/packages/vtable/src/PivotTable.ts @@ -687,7 +687,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -700,7 +700,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -720,10 +720,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -779,7 +779,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -792,7 +792,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -816,10 +816,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const valueNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -859,7 +859,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -872,7 +872,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -889,10 +889,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -932,7 +932,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -945,7 +945,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -961,10 +961,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -1001,7 +1001,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -1014,7 +1014,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -1031,10 +1031,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -1069,7 +1069,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -1082,7 +1082,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const aggregator = this.dataset.getAggregator( // !this.internalProps.layoutMap.indicatorsAsCol ? rowKeys.slice(0, -1) : rowKeys, @@ -1099,10 +1099,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { //数据为行列树结构 根据row col获取对应的维度名称 查找到对应值 const cellDimensionPath = this.internalProps.layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); const treeNode = this.flatDataToObjects.getTreeNode( rowKeys, @@ -2221,7 +2221,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths ?.filter((path: any) => { @@ -2234,7 +2234,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { index }; } - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); this.dataset.changeTreeNodeValue( rowKeys, @@ -2262,10 +2262,10 @@ export class PivotTable extends BaseTable implements PivotTableAPI { ); } else { const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); this.flatDataToObjects.changeTreeNodeValue( rowKeys, diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index 7a5fd81770..e5abadd9bc 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -107,6 +107,8 @@ export class Dataset { indicatorStatistics: { max: Aggregator; min: Aggregator; total: Aggregator }[] = []; stringJoinChar = String.fromCharCode(0); + private readonly groupedGrandTotalKey = String.fromCharCode(1) + 'vtable_grouped_grand_total'; + private readonly groupedGrandTotalSubtotalKey = String.fromCharCode(1) + 'vtable_grouped_grand_total_subtotal'; //缓存rows对应每个值是否为汇总字段 private rowsIsTotal: boolean[] = []; private colsIsTotal: boolean[] = []; @@ -330,7 +332,8 @@ export class Dataset { this.rowGrandTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, - this.rowSubTotalLabel + this.rowSubTotalLabel, + this.totals?.row?.showSubTotalsOnTop ?? false ); } else { this.rowHeaderTree = this.ArrToTree( @@ -345,7 +348,8 @@ export class Dataset { this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.showSubTotalsOnTop ?? false, - this.totals?.row?.grandTotalDimensions + this.totals?.row?.grandTotalDimensions, + true ); } } @@ -380,7 +384,8 @@ export class Dataset { this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, this.totals?.column?.showSubTotalsOnLeft ?? false, - this.totals?.column?.grandTotalDimensions + this.totals?.column?.grandTotalDimensions, + false ); // } } @@ -738,7 +743,11 @@ export class Dataset { return rk in record; }) ) { - rowKey.push(this.rowGrandTotalLabel); + if (this.getGrandTotalDimensionIndexes(this.rows, this.dataConfig.totals.row.grandTotalDimensions).length) { + rowKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + rowKey.push(this.rowGrandTotalLabel); + } isToTalRecord = true; break; } else if ( @@ -798,7 +807,14 @@ export class Dataset { return ck in record; }) ) { - colKey.push(this.colGrandTotalLabel); + if ( + this.getGrandTotalDimensionIndexes(this.columns, this.dataConfig.totals.column.grandTotalDimensions) + .length + ) { + colKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + colKey.push(this.colGrandTotalLabel); + } isToTalRecord = true; break; } else if ( @@ -1068,7 +1084,8 @@ export class Dataset { this.rowGrandTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, - this.rowSubTotalLabel + this.rowSubTotalLabel, + this.totals?.row?.showSubTotalsOnTop ?? false ); } else { this.rowHeaderTree = this.ArrToTree( @@ -1083,7 +1100,8 @@ export class Dataset { this.rowSubTotalLabel, this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.showSubTotalsOnTop ?? false, - this.totals?.row?.grandTotalDimensions + this.totals?.row?.grandTotalDimensions, + true ); } } @@ -1101,7 +1119,8 @@ export class Dataset { this.colSubTotalLabel, this.totals?.column?.showGrandTotalsOnLeft ?? false, this.totals?.column?.showSubTotalsOnLeft ?? false, - this.totals?.column?.grandTotalDimensions + this.totals?.column?.grandTotalDimensions, + false ); } // this.rowKeysPath_FULL = this.TreeToArr( @@ -1193,8 +1212,8 @@ export class Dataset { } //#endregion // let agg; - let flatRowKey; - let flatColKey; + let flatRowKey: string; + let flatColKey: string; if (typeof rowKey === 'string') { flatRowKey = rowKey; } else { @@ -1249,6 +1268,39 @@ export class Dataset { // flatColKey = colKey.join(this.stringJoinChar); flatColKey = join(colKey, this.stringJoinChar); } + + if ( + Array.isArray(rowKey) && + rowKey[0] === this.rowGrandTotalLabel && + !this.tree[flatRowKey] && + this.getGrandTotalDimensionIndexes( + this.rows.filter((_, index) => this.rowsHasValue[index]), + this.totals?.row?.grandTotalDimensions + ).length + ) { + const internalRowKey = rowKey.slice(); + internalRowKey[0] = this.groupedGrandTotalKey; + if (internalRowKey.length === 2 && internalRowKey[1] === this.rowSubTotalLabel) { + internalRowKey[1] = this.groupedGrandTotalSubtotalKey; + } + flatRowKey = join(internalRowKey, this.stringJoinChar); + } + if ( + Array.isArray(colKey) && + colKey[0] === this.colGrandTotalLabel && + !Object.keys(this.tree).some(rowTreeKey => this.tree[rowTreeKey]?.[flatColKey]) && + this.getGrandTotalDimensionIndexes( + this.columns.filter((_, index) => this.columnsHasValue[index]), + this.totals?.column?.grandTotalDimensions + ).length + ) { + const internalColKey = colKey.slice(); + internalColKey[0] = this.groupedGrandTotalKey; + if (internalColKey.length === 2 && internalColKey[1] === this.colSubTotalLabel) { + internalColKey[1] = this.groupedGrandTotalSubtotalKey; + } + flatColKey = join(internalColKey, this.stringJoinChar); + } //TODO 原有逻辑 但这里先强制跳过 // if ( rowKey.length === 0 && colKey.length === 0) { // agg = this.allTotal; @@ -1661,13 +1713,12 @@ export class Dataset { } } } - if ((that.totals?.column?.showGrandTotals || this.rows.length === 0) && colKey[0] !== that.colGrandTotalLabel) { + if ((that.totals?.column?.showGrandTotals || this.rows.length === 0) && colKey[0] !== this.groupedGrandTotalKey) { const flatColTotalKeys = this.getGrandTotalKeys( colKey, - that.columns, + that.columns.filter((_, index) => that.columnsHasValue[index]), that.totals?.column?.grandTotalDimensions, - that.colGrandTotalLabel, - that.colSubTotalLabel + that.colGrandTotalLabel ); for (const flatColTotalKey of flatColTotalKeys) { if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { @@ -1713,6 +1764,7 @@ export class Dataset { ]({ key: toComputeIndicatorKeys[i], field: aggRule?.field ?? toComputeIndicatorKeys[i], + aggregationFun: aggRule?.aggregationFun, formatFun: aggRule?.formatFun ?? ( @@ -1726,7 +1778,14 @@ export class Dataset { }); } if (flatColTotalKey !== flatColKey) { - this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + const sourceAggregator = that.tree[flatRowKey]?.[flatColKey]?.[i]; + if (this.tree[flatRowKey][flatColTotalKey][i].type === AggregationType.CUSTOM) { + sourceAggregator?.records.forEach(record => { + this.tree[flatRowKey][flatColTotalKey][i].push(record); + }); + } else { + this.tree[flatRowKey][flatColTotalKey][i].push(sourceAggregator); + } } } } @@ -1835,14 +1894,13 @@ export class Dataset { if ( (that.totals?.row?.showGrandTotals || this.columns.length === 0) && - rowKey[0] !== that.rowGrandTotalLabel + rowKey[0] !== this.groupedGrandTotalKey ) { const flatRowTotalKeys = this.getGrandTotalKeys( rowKey, - that.rows, + that.rows.filter((_, index) => that.rowsHasValue[index]), that.totals?.row?.grandTotalDimensions, - that.rowGrandTotalLabel, - that.rowSubTotalLabel + that.rowGrandTotalLabel ); for (const flatRowTotalKey of flatRowTotalKeys) { if (!this.tree[flatRowTotalKey]) { @@ -1882,6 +1940,7 @@ export class Dataset { ]({ key: toComputeIndicatorKeys[i], field: aggRule?.field ?? toComputeIndicatorKeys[i], + aggregationFun: aggRule?.aggregationFun, formatFun: aggRule?.formatFun ?? ( @@ -1896,7 +1955,14 @@ export class Dataset { } } if (flatRowTotalKey !== flatRowKey) { - this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + const sourceAggregator = that.tree[flatRowKey]?.[flatColKey]?.[i]; + if (this.tree[flatRowTotalKey][flatColKey][i].type === AggregationType.CUSTOM) { + sourceAggregator?.records.forEach(record => { + this.tree[flatRowTotalKey][flatColKey][i].push(record); + }); + } else { + this.tree[flatRowTotalKey][flatColKey][i].push(sourceAggregator); + } } } } @@ -1967,19 +2033,44 @@ export class Dataset { .sort((a, b) => a - b); } + private getGroupedGrandTotalPaths( + keys: string[][], + dimensions: string[], + grandTotalDimensions: string[] | undefined, + isRow: boolean = true + ) { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + const groupedPaths = new Map(); + keys.forEach(key => { + const path = dimensionIndexes.map(index => key[index]); + groupedPaths.set(join(path, this.stringJoinChar), path); + }); + return Array.from(groupedPaths.values()).sort( + this.arrSort( + dimensionIndexes.map(index => dimensions[index]), + isRow + ) + ); + } + private getGrandTotalKeys( dimensionKey: string[], dimensions: string[], grandTotalDimensions: string[] | undefined, - grandTotalLabel: string, - subTotalLabel: string + grandTotalLabel: string ) { const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); if (!dimensionIndexes.length) { return [grandTotalLabel]; } - const groupedKey = [grandTotalLabel, ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex])]; - return [join(groupedKey, this.stringJoinChar), join([grandTotalLabel, subTotalLabel], this.stringJoinChar)]; + const groupedKey = [ + this.groupedGrandTotalKey, + ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex]) + ]; + return [ + join(groupedKey, this.stringJoinChar), + join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar) + ]; } private ArrToTree1( @@ -1990,7 +2081,8 @@ export class Dataset { grandTotalLabel: string, showGrandTotalsOnTop: boolean, grandTotalDimensions?: string[], - subTotalLabel?: string + subTotalLabel?: string, + showSubTotalsOnTop?: boolean ) { /** * @@ -2069,22 +2161,24 @@ export class Dataset { } else { const grandTotalNode: any = { value: grandTotalLabel, + dataValue: this.groupedGrandTotalKey, dimensionKey: rows[0], children: [], role: 'grand-total' }; const grandTotalMap = new Map(); - arr.forEach(list => { + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, true).forEach(list => { let parent = grandTotalNode; - const path = [grandTotalLabel]; + const path = [this.groupedGrandTotalKey]; grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { - const value = list[dimensionIndex]; + const value = list[index]; path.push(value); const flatKey = join(path, concatStr); let item = grandTotalMap.get(flatKey); if (!item) { item = { value, + dataValue: value, dimensionKey: rows[dimensionIndex], children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] }; @@ -2094,11 +2188,17 @@ export class Dataset { parent = item; }); }); - grandTotalNode.children.push({ + const totalChild = { value: subTotalLabel, + dataValue: this.groupedGrandTotalSubtotalKey, dimensionKey: rows[grandTotalDimensionIndexes[0]], children: createIndicatorChildren() - }); + }; + if (showSubTotalsOnTop) { + grandTotalNode.children.unshift(totalChild); + } else { + grandTotalNode.children.push(totalChild); + } if (showGrandTotalsOnTop) { result.unshift(grandTotalNode); } else { @@ -2124,7 +2224,8 @@ export class Dataset { subTotalLabel: string, showGrandTotalsOnTop: boolean, showSubTotalsOnTop: boolean, - grandTotalDimensions?: string[] + grandTotalDimensions?: string[], + isRow?: boolean ) { /** * @@ -2246,8 +2347,16 @@ export class Dataset { value: indicator.title }; }) ?? []; - const node: { value: string; dimensionKey: string; children: any[]; levelSpan: number; role: string } = { + const node: { + value: string; + dataValue?: string; + dimensionKey: string; + children: any[]; + levelSpan: number; + role: string; + } = { value: grandTotalLabel, // getId(item?.id, 1), + dataValue: grandTotalDimensionIndexes.length ? this.groupedGrandTotalKey : undefined, dimensionKey: rows[0], levelSpan: grandTotalDimensionIndexes[0] ?? subTotalFlags.length, children: [], @@ -2255,11 +2364,11 @@ export class Dataset { }; if (grandTotalDimensionIndexes.length) { const grandTotalMap = new Map(); - arr.forEach(list => { + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, isRow).forEach(list => { let parent = node; - const path = [grandTotalLabel]; + const path = [this.groupedGrandTotalKey]; grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { - const value = list[dimensionIndex]; + const value = list[index]; path.push(value); const flatKey = join(path, concatStr); let item = grandTotalMap.get(flatKey); @@ -2267,6 +2376,7 @@ export class Dataset { const nextDimensionIndex = grandTotalDimensionIndexes[index + 1] ?? rows.length; item = { value, + dataValue: value, dimensionKey: rows[dimensionIndex], levelSpan: nextDimensionIndex - dimensionIndex, children: index === grandTotalDimensionIndexes.length - 1 ? createIndicatorChildren() : [] @@ -2279,6 +2389,7 @@ export class Dataset { }); const totalChild = { value: subTotalLabel, + dataValue: this.groupedGrandTotalSubtotalKey, dimensionKey: rows[grandTotalDimensionIndexes[0]], levelSpan: rows.length - grandTotalDimensionIndexes[0], children: createIndicatorChildren() @@ -2538,10 +2649,9 @@ export class Dataset { if (this.totals?.row?.showGrandTotals) { const rowGrandTotalKeys = this.getGrandTotalKeys( rowKeyParts, - this.rows, + this.rows.filter((_, index) => this.rowsHasValue[index]), this.totals.row.grandTotalDimensions, - this.rowGrandTotalLabel, - this.rowSubTotalLabel + this.rowGrandTotalLabel ); for (const rowGrandTotalKey of rowGrandTotalKeys) { if (rowAggregateKeys.indexOf(rowGrandTotalKey) === -1) { @@ -2569,10 +2679,9 @@ export class Dataset { if (this.totals?.column?.showGrandTotals) { const colGrandTotalKeys = this.getGrandTotalKeys( colKeyParts, - this.columns, + this.columns.filter((_, index) => this.columnsHasValue[index]), this.totals.column.grandTotalDimensions, - this.colGrandTotalLabel, - this.colSubTotalLabel + this.colGrandTotalLabel ); for (const colGrandTotalKey of colGrandTotalKeys) { if (colAggregateKeys.indexOf(colGrandTotalKey) === -1) { diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index c05a9dacb6..50e61966f1 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -2348,6 +2348,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { dimensionKey?: string; indicatorKey?: string; value?: string; + dataValue?: string; virtual?: boolean; role?: CellPivotRole; } = {}; @@ -2359,6 +2360,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { (colHeader.indicatorKey ? this.getIndicatorInfoByIndicatorKey(colHeader.indicatorKey)?.title ?? '' : colHeader.value); + colHeaderPath.dataValue = colHeader.dataValue; colHeaderPath.virtual = colHeader.virtual; colHeaderPath.role = colHeader.role; headerPaths.colHeaderPaths!.push(colHeaderPath); @@ -2370,6 +2372,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { dimensionKey?: string; indicatorKey?: string; value?: string; + dataValue?: string; virtual?: boolean; role?: CellPivotRole; } = {}; @@ -2381,6 +2384,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { (rowHeader.indicatorKey ? this.getIndicatorInfoByIndicatorKey(rowHeader.indicatorKey)?.title ?? '' : rowHeader.value); + rowHeaderPath.dataValue = rowHeader.dataValue; rowHeaderPath.virtual = rowHeader.virtual; rowHeaderPath.role = rowHeader.role; headerPaths.rowHeaderPaths!.push(rowHeaderPath); diff --git a/packages/vtable/src/tools/get-data-path/index.ts b/packages/vtable/src/tools/get-data-path/index.ts index dcf5eb4f17..b2662f2933 100644 --- a/packages/vtable/src/tools/get-data-path/index.ts +++ b/packages/vtable/src/tools/get-data-path/index.ts @@ -69,10 +69,10 @@ export function getDataCellPath( const cellDimensionPath = layoutMap.getCellHeaderPaths(col, row); const colKeys = cellDimensionPath.colHeaderPaths.map((colPath: any) => { - return colPath.indicatorKey ?? colPath.value; + return colPath.indicatorKey ?? colPath.dataValue ?? colPath.value; }); const rowKeys = cellDimensionPath.rowHeaderPaths.map((rowPath: any) => { - return rowPath.indicatorKey ?? rowPath.value; + return rowPath.indicatorKey ?? rowPath.dataValue ?? rowPath.value; }); // const aggregator = dataset.getAggregator( // rowKey[rowKey.length - 1], diff --git a/packages/vtable/src/ts-types/common.ts b/packages/vtable/src/ts-types/common.ts index a05a30cd9e..aea73b3083 100644 --- a/packages/vtable/src/ts-types/common.ts +++ b/packages/vtable/src/ts-types/common.ts @@ -104,6 +104,8 @@ export type IPivotTableCellHeaderPaths = { export interface IDimensionInfo { dimensionKey?: string; value?: string; + /** Internal value used to locate aggregated data when it differs from the displayed value. */ + dataValue?: string; indicatorKey?: string; isPivotCorner?: boolean; virtual?: boolean; From 93721897c78d53b29821c949e317ead7f065fa1d Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 31 Aug 2026 17:03:46 +0800 Subject: [PATCH 3/8] fix: complete grouped grand total integrations Handle grouped totals across chart paths, custom edits, and indicator sorting. Support external totals and grid-tree lookup. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/pivotTable-analysis.test.ts | 198 ++++++++++++++-- packages/vtable/src/dataset/dataset.ts | 221 +++++++++++------- .../vtable/src/layout/pivot-header-layout.ts | 4 +- .../src/ts-types/dataset/aggregation.ts | 24 ++ 4 files changed, 334 insertions(+), 113 deletions(-) diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index 23ab65c571..e8477f00a8 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -410,13 +410,37 @@ describe('pivotTable grand total grouped by lower-level dimension', () => { } }); + function findBodyCell(rowValues: string[], colValues: string[] = [], role?: 'normal' | 'grand-total') { + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const paths = pivotTable.getCellHeaderPaths(col, row); + const currentRowValues = paths.rowHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const currentColValues = paths.colHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const rowRole = pivotTable.getCellPivotRole(col, row).rowRole; + if ( + currentRowValues.join('|') === rowValues.join('|') && + currentColValues.join('|') === colValues.join('|') && + (!role || rowRole === role) + ) { + return { col, row }; + } + } + } + throw new Error(`Unable to find body cell for ${rowValues.join('/')}`); + } + test('creates grouped grand total headers and values', () => { const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); expect(grandTotalNode.levelSpan).toBe(1); - expect(grandTotalNode.children.map(node => node.value)).toEqual( - expect.arrayContaining(['银票', '商票', '信用证', '小计']) - ); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '信用证', '小计']); expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(400); expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(200); expect(pivotTable.dataset.getAggregator(['合计', '信用证'], [], 'balance').value()).toBe(400); @@ -432,18 +456,18 @@ describe('pivotTable grand total grouped by lower-level dimension', () => { ]); const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.value === '合计'); - expect(grandTotalNode.children.map(node => node.value)).toEqual( - expect.arrayContaining(['银票', '商票', '信用证', '小计']) - ); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '信用证', '商票', '小计']); }); test('recalculates grouped and overall grand totals after editing', () => { - pivotTable.dataset.changeTreeNodeValue(['公司一', '银票'], [], 'balance', 150); + const detailCell = findBodyCell(['公司一', '银票'], [], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '150'); + const groupedTotalCell = findBodyCell(['合计', '银票'], [], 'grand-total'); + const overallTotalCell = findBodyCell(['合计', '小计'], [], 'grand-total'); - const groupedGrandTotal = pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance'); - expect(groupedGrandTotal.children.map(child => child.value())).toEqual([150, 300]); - expect(groupedGrandTotal.value()).toBe(450); - expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(1050); + expect(pivotTable.getCellOriginValue(detailCell.col, detailCell.row)).toBe(150); + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).toBe(450); + expect(pivotTable.getCellOriginValue(overallTotalCell.col, overallTotalCell.row)).toBe(1050); pivotTable.release(); }); }); @@ -482,15 +506,32 @@ describe('pivotTable column grand total grouped by lower-level dimension', () => const grandTotalNode = pivotTable.dataset.colHeaderTree.find(node => node.value === '合计'); expect(grandTotalNode.levelSpan).toBe(1); - expect(grandTotalNode.children.map(node => node.value)).toEqual( - expect.arrayContaining(['银票', '商票', '信用证', '小计']) - ); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '信用证', '小计']); expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(400); expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1000); - pivotTable.dataset.changeTreeNodeValue([], ['公司一', '银票'], 'balance', 150); - expect(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(450); - expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1050); + const findBodyCell = (colValues: string[], role: 'normal' | 'grand-total') => { + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const paths = pivotTable.getCellHeaderPaths(col, row); + const values = paths.colHeaderPaths.filter(path => path.dimensionKey).map(path => path.value as string); + if (values.join('|') === colValues.join('|') && pivotTable.getCellPivotRole(col, row).colRole === role) { + return { col, row }; + } + } + } + throw new Error(`Unable to find body cell for ${colValues.join('/')}`); + }; + const detailCell = findBodyCell(['公司一', '银票'], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '150'); + const groupedTotalCell = findBodyCell(['合计', '银票'], 'grand-total'); + const overallTotalCell = findBodyCell(['合计', '小计'], 'grand-total'); + + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).toBe(450); + expect(pivotTable.getCellOriginValue(overallTotalCell.col, overallTotalCell.row)).toBe(1050); pivotTable.release(); }); }); @@ -500,10 +541,12 @@ describe('pivotTable grouped grand total edge cases', () => { records: any[], options: { rows?: string[]; - rowHierarchyType?: 'grid' | 'tree'; + rowHierarchyType?: 'grid' | 'tree' | 'grid-tree'; grandTotalDimensions?: string[]; showSubTotalsOnTop?: boolean; + showSubTotalsOnTreeNode?: boolean; aggregationRules?: any[]; + sortRules?: any[]; } = {} ) { const containerDom: HTMLElement = createDiv(); @@ -516,9 +559,12 @@ describe('pivotTable grouped grand total edge cases', () => { indicators: ['balance'], indicatorsAsCol: true, rowHierarchyType: options.rowHierarchyType, + rowExpandLevel: 3, records, dataConfig: { + updateAggregationOnEditCell: true, aggregationRules: options.aggregationRules, + sortRules: options.sortRules, totals: { row: { showGrandTotals: true, @@ -526,13 +572,42 @@ describe('pivotTable grouped grand total edge cases', () => { grandTotalDimensions: options.grandTotalDimensions ?? ['type'], grandTotalLabel: '合计', subTotalLabel: '小计', - showSubTotalsOnTop: options.showSubTotalsOnTop + showSubTotalsOnTop: options.showSubTotalsOnTop, + showSubTotalsOnTreeNode: options.showSubTotalsOnTreeNode } } } }); } + function findBodyCell( + pivotTable: PivotTable, + rowValues: string[], + role?: 'normal' | 'grand-total' + ): { col: number; row: number } { + const candidates: string[] = []; + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const values = pivotTable + .getCellHeaderPaths(col, row) + .rowHeaderPaths.filter(path => path.dimensionKey) + .map(path => path.value as string); + candidates.push(`${values.join('/')} (${pivotTable.getCellPivotRole(col, row).rowRole})`); + if ( + values.join('|') === rowValues.join('|') && + (!role || pivotTable.getCellPivotRole(col, row).rowRole === role) + ) { + return { col, row }; + } + } + } + const availableCells = Array.from(new Set(candidates)).join(', '); + throw new Error(`Unable to find body cell for ${rowValues.join('/')}: ${availableCells}`); + } + test('keeps records whose dimension values match total labels separate from grouped totals', () => { const pivotTable = createPivotTable([ { organization: '合计', type: '小计', balance: 100 }, @@ -549,6 +624,8 @@ describe('pivotTable grouped grand total edge cases', () => { expect( pivotTable.dataset.getAggregator([grandTotalNode.dataValue, overallNode.dataValue], [], 'balance').value() ).toBe(400); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', '小计'], 'grand-total'); + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).toBe(400); pivotTable.release(); }); @@ -610,7 +687,9 @@ describe('pivotTable grouped grand total edge cases', () => { const pivotTable = createPivotTable( [ { organization: '公司一', type: '银票', balance: 100 }, - { organization: '公司二', type: '银票', balance: 300 } + { organization: '公司一', type: '银票', balance: 200 }, + { organization: '公司二', type: '商票', balance: 300 }, + { organization: '公司二', type: '商票', balance: 500 } ], { aggregationRules: [ @@ -624,8 +703,14 @@ describe('pivotTable grouped grand total edge cases', () => { } ); - expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(200); - expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(200); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(150); + expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(275); + + const detailCell = findBodyCell(pivotTable, ['公司一', '银票'], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '500'); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(500); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBeCloseTo(433.33, 2); pivotTable.release(); }); @@ -641,6 +726,75 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('uses custom grouped total records for retained dimensions', () => { + const pivotTable = createPivotTable([ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 }, + { type: '银票', balance: 999 } + ]); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(999); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(400); + pivotTable.release(); + }); + + test('sorts grouped totals by indicator values', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '商票', balance: 300 }, + { organization: '公司二', type: '信用证', balance: 200 } + ], + { + sortRules: [ + { + sortField: 'type', + sortByIndicator: 'balance', + sortType: VTable.TYPES.SortType.DESC, + query: [] + } + ] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['商票', '信用证', '银票', '小计']); + pivotTable.release(); + }); + + test('supports multiple retained dimensions and grid-tree lookup', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', category: '票据', type: '银票', balance: 100 }, + { organization: '公司二', category: '票据', type: '商票', balance: 300 }, + { organization: '公司三', category: '贷款', type: '信用贷', balance: 200 } + ], + { + rows: ['organization', 'category', 'type'], + rowHierarchyType: 'grid-tree', + grandTotalDimensions: ['category', 'type'], + showSubTotalsOnTreeNode: true + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); + expect(grandTotalNode.children[0].children.map(node => node.value)).toEqual(['银票', '商票']); + expect(grandTotalNode.children[0].levelSpan).toBe(1); + expect(pivotTable.dataset.getAggregator(['合计', '票据', '银票'], [], 'balance').value()).toBe(100); + const groupedCell = findBodyCell(pivotTable, ['合计', '票据', '银票'], 'grand-total'); + expect(pivotTable.getCellOriginValue(groupedCell.col, groupedCell.row)).toBe(100); + const groupedPath = pivotTable + .getCellHeaderPaths(groupedCell.col, groupedCell.row) + .rowHeaderPaths.filter(path => path.dimensionKey) + .map(path => path.dataValue ?? path.value) + .join(pivotTable.dataset.stringJoinChar); + expect((pivotTable.internalProps.layoutMap as any).getRowKeysPath(groupedCell.col, groupedCell.row)).toBe( + groupedPath + ); + pivotTable.release(); + }); + test('orders tree grouped totals by retained dimensions and honors subtotal placement', () => { const pivotTable = createPivotTable( [ diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index e5abadd9bc..6c963a18e6 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -728,39 +728,51 @@ export class Dataset { } else { const rowKey: string[] = []; rowKeys.push({ rowKey, indicatorKey: assignedIndicatorKey }); - for (let l = 0, len1 = this.rows.length; l < len1; l++) { - const rowAttr = this.rows[l]; - if (rowAttr in record) { - this.rowsHasValue[l] = true; - rowKey.push(record[rowAttr]); - } else if (rowAttr !== IndicatorDimensionKeyPlaceholder) { - //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 - if ( - this.dataConfig?.totals?.row?.showGrandTotals && - l === 0 && - !this.rows.find((rk: string) => { - // 判断没有其他字段在record中 例如rows中维度有省份和城市,当前在判断省份 数据中确实省份自动 可以认为是行总计的前提是城市也不应该存在 - return rk in record; - }) - ) { - if (this.getGrandTotalDimensionIndexes(this.rows, this.dataConfig.totals.row.grandTotalDimensions).length) { - rowKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); - } else { - rowKey.push(this.rowGrandTotalLabel); - } - isToTalRecord = true; - break; - } else if ( - // this.dataConfig?.totals?.row?.showSubTotals && - this.dataConfig?.totals?.row?.subTotalsDimensions && - this.dataConfig?.totals?.row?.subTotalsDimensions.indexOf(this.rows[l - 1]) >= 0 - ) { - if (this.rowHierarchyType !== 'tree') { - //如果是tree的话 不附加标签'小计' - rowKey.push(this.rowSubTotalLabel); + const customGroupedGrandTotalKey = this.getCustomGroupedGrandTotalKey( + record, + this.rows, + this.dataConfig?.totals?.row?.grandTotalDimensions + ); + if (customGroupedGrandTotalKey) { + rowKey.push(...customGroupedGrandTotalKey); + isToTalRecord = true; + } else { + for (let l = 0, len1 = this.rows.length; l < len1; l++) { + const rowAttr = this.rows[l]; + if (rowAttr in record) { + this.rowsHasValue[l] = true; + rowKey.push(record[rowAttr]); + } else if (rowAttr !== IndicatorDimensionKeyPlaceholder) { + //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 + if ( + this.dataConfig?.totals?.row?.showGrandTotals && + l === 0 && + !this.rows.find((rk: string) => { + // 判断没有其他字段在record中 例如rows中维度有省份和城市,当前在判断省份 数据中确实省份自动 可以认为是行总计的前提是城市也不应该存在 + return rk in record; + }) + ) { + if ( + this.getGrandTotalDimensionIndexes(this.rows, this.dataConfig.totals.row.grandTotalDimensions).length + ) { + rowKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + rowKey.push(this.rowGrandTotalLabel); + } + isToTalRecord = true; + break; + } else if ( + // this.dataConfig?.totals?.row?.showSubTotals && + this.dataConfig?.totals?.row?.subTotalsDimensions && + this.dataConfig?.totals?.row?.subTotalsDimensions.indexOf(this.rows[l - 1]) >= 0 + ) { + if (this.rowHierarchyType !== 'tree') { + //如果是tree的话 不附加标签'小计' + rowKey.push(this.rowSubTotalLabel); + } + isToTalRecord = true; + break; } - isToTalRecord = true; - break; } } } @@ -792,41 +804,51 @@ export class Dataset { } else { const colKey: string[] = []; colKeys.push({ colKey, indicatorKey: assignedIndicatorKey }); - for (let n = 0, len2 = this.columns.length; n < len2; n++) { - const colAttr = this.columns[n]; - if (colAttr in record) { - this.columnsHasValue[n] = true; - colKey.push(record[colAttr]); - } else if (colAttr !== IndicatorDimensionKeyPlaceholder) { - //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 - if ( - this.dataConfig?.totals?.column?.showGrandTotals && - n === 0 && - !this.columns.find((ck: string) => { - // 判断没有其他字段在record中 - return ck in record; - }) - ) { + const customGroupedGrandTotalKey = this.getCustomGroupedGrandTotalKey( + record, + this.columns, + this.dataConfig?.totals?.column?.grandTotalDimensions + ); + if (customGroupedGrandTotalKey) { + colKey.push(...customGroupedGrandTotalKey); + isToTalRecord = true; + } else { + for (let n = 0, len2 = this.columns.length; n < len2; n++) { + const colAttr = this.columns[n]; + if (colAttr in record) { + this.columnsHasValue[n] = true; + colKey.push(record[colAttr]); + } else if (colAttr !== IndicatorDimensionKeyPlaceholder) { + //如果数据中缺失某个维度的值 可以认为是用户传入的汇总数据 if ( - this.getGrandTotalDimensionIndexes(this.columns, this.dataConfig.totals.column.grandTotalDimensions) - .length + this.dataConfig?.totals?.column?.showGrandTotals && + n === 0 && + !this.columns.find((ck: string) => { + // 判断没有其他字段在record中 + return ck in record; + }) ) { - colKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); - } else { - colKey.push(this.colGrandTotalLabel); + if ( + this.getGrandTotalDimensionIndexes(this.columns, this.dataConfig.totals.column.grandTotalDimensions) + .length + ) { + colKey.push(this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey); + } else { + colKey.push(this.colGrandTotalLabel); + } + isToTalRecord = true; + break; + } else if ( + // this.dataConfig?.totals?.column?.showSubTotals && + this.dataConfig?.totals?.column?.subTotalsDimensions && + this.dataConfig?.totals?.column?.subTotalsDimensions.indexOf(this.columns[n - 1]) >= 0 + ) { + // if (this.columnHierarchyType === 'grid') { + colKey.push(this.colSubTotalLabel); + // } + isToTalRecord = true; + break; } - isToTalRecord = true; - break; - } else if ( - // this.dataConfig?.totals?.column?.showSubTotals && - this.dataConfig?.totals?.column?.subTotalsDimensions && - this.dataConfig?.totals?.column?.subTotalsDimensions.indexOf(this.columns[n - 1]) >= 0 - ) { - // if (this.columnHierarchyType === 'grid') { - colKey.push(this.colSubTotalLabel); - // } - isToTalRecord = true; - break; } } } @@ -1227,7 +1249,7 @@ export class Dataset { } if (rowKey.length < this.rows.length && this.rowHierarchyType === 'grid-tree') { // 如果是平铺树结构 小计需要处理补充到rowKey中 - if (rowKey[0] === this.rowGrandTotalLabel) { + if (rowKey[0] === this.rowGrandTotalLabel || rowKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.row?.subTotalsDimensions && this.totals?.row?.subTotalsDimensions?.length >= 1 && @@ -1254,7 +1276,7 @@ export class Dataset { }); } if (colKey.length < this.columns.length && this.columnHierarchyType === 'grid-tree') { - if (colKey[0] === this.colGrandTotalLabel) { + if (colKey[0] === this.colGrandTotalLabel || colKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.column?.subTotalsDimensions && this.totals?.column?.subTotalsDimensions?.length >= 1 && @@ -1452,7 +1474,7 @@ export class Dataset { * @param fieldArr 排序维度名称 如行rows 列columns * @returns */ - arrSort(fieldArr: string[], isRow: boolean) { + arrSort(fieldArr: string[], isRow: boolean, pathPrefix?: string[]) { let field; const that = this; const sortersArr: any[] = function (_this: any) { @@ -1499,7 +1521,7 @@ export class Dataset { if (sorter.sortRule?.sortByIndicator) { let aChanged = a; let bChanged = b; - if (sorter.fieldIndex < fieldArr.length - 1) { + if (!pathPrefix?.length && sorter.fieldIndex < fieldArr.length - 1) { aChanged = a.slice(0, sorter.fieldIndex + 1); if (that.rowHierarchyType === 'grid' && isRow) { aChanged.push(that.rowSubTotalLabel); @@ -1513,6 +1535,10 @@ export class Dataset { bChanged.push(that.colSubTotalLabel); } } + if (pathPrefix?.length) { + aChanged = [...pathPrefix, ...aChanged]; + bChanged = [...pathPrefix, ...bChanged]; + } comparison = sorter.func(aChanged, bChanged, sorter.sortRule?.sortType); } else { comparison = sorter.func?.(a[sorter.fieldIndex], b[sorter.fieldIndex], sorter.sortRule?.sortType); @@ -1550,14 +1576,16 @@ export class Dataset { that.rowHierarchyType === 'grid' && rowKey.length < that.rows.length && rowKey[rowKey.length - 1] !== that.rowSubTotalLabel && - rowKey[rowKey.length - 1] !== that.rowGrandTotalLabel + rowKey[rowKey.length - 1] !== that.rowGrandTotalLabel && + rowKey[0] !== that.groupedGrandTotalKey ) { rowKey.push(that.rowSubTotalLabel); } if ( colKey.length < that.columns.length && colKey[colKey.length - 1] !== that.colSubTotalLabel && - colKey[colKey.length - 1] !== that.colGrandTotalLabel + colKey[colKey.length - 1] !== that.colGrandTotalLabel && + colKey[0] !== that.groupedGrandTotalKey ) { colKey.push(that.colSubTotalLabel); } @@ -1776,16 +1804,15 @@ export class Dataset { }) as IIndicator )?.format }); + if ( + this.tree[flatRowKey][flatColTotalKey][i] instanceof CustomAggregator && + flatColTotalKey.startsWith(this.groupedGrandTotalKey) + ) { + (this.tree[flatRowKey][flatColTotalKey][i] as CustomAggregator).recalculateFromChildren = true; + } } if (flatColTotalKey !== flatColKey) { - const sourceAggregator = that.tree[flatRowKey]?.[flatColKey]?.[i]; - if (this.tree[flatRowKey][flatColTotalKey][i].type === AggregationType.CUSTOM) { - sourceAggregator?.records.forEach(record => { - this.tree[flatRowKey][flatColTotalKey][i].push(record); - }); - } else { - this.tree[flatRowKey][flatColTotalKey][i].push(sourceAggregator); - } + this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } } } @@ -1952,17 +1979,16 @@ export class Dataset { }) as IIndicator )?.format }); + if ( + this.tree[flatRowTotalKey][flatColKey][i] instanceof CustomAggregator && + flatRowTotalKey.startsWith(this.groupedGrandTotalKey) + ) { + (this.tree[flatRowTotalKey][flatColKey][i] as CustomAggregator).recalculateFromChildren = true; + } } } if (flatRowTotalKey !== flatRowKey) { - const sourceAggregator = that.tree[flatRowKey]?.[flatColKey]?.[i]; - if (this.tree[flatRowTotalKey][flatColKey][i].type === AggregationType.CUSTOM) { - sourceAggregator?.records.forEach(record => { - this.tree[flatRowTotalKey][flatColKey][i].push(record); - }); - } else { - this.tree[flatRowTotalKey][flatColKey][i].push(sourceAggregator); - } + this.tree[flatRowTotalKey][flatColKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); } } } @@ -2033,6 +2059,22 @@ export class Dataset { .sort((a, b) => a - b); } + private getCustomGroupedGrandTotalKey( + record: any, + dimensions: string[], + grandTotalDimensions?: string[] + ): string[] | undefined { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + if ( + !dimensionIndexes.length || + !dimensionIndexes.every(index => dimensions[index] in record) || + dimensions.some((dimension, index) => dimensionIndexes.indexOf(index) === -1 && dimension in record) + ) { + return undefined; + } + return [this.groupedGrandTotalKey, ...dimensionIndexes.map(index => record[dimensions[index]])]; + } + private getGroupedGrandTotalPaths( keys: string[][], dimensions: string[], @@ -2048,7 +2090,8 @@ export class Dataset { return Array.from(groupedPaths.values()).sort( this.arrSort( dimensionIndexes.map(index => dimensions[index]), - isRow + isRow, + [this.groupedGrandTotalKey] ) ); } @@ -2472,7 +2515,7 @@ export class Dataset { } if (rowKey.length < this.rows.length && this.rowHierarchyType === 'grid-tree') { // 如果是平铺树结构 小计需要处理补充到rowKey中 - if (rowKey[0] === this.rowGrandTotalLabel) { + if (rowKey[0] === this.rowGrandTotalLabel || rowKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.row?.subTotalsDimensions && this.totals?.row?.subTotalsDimensions?.length >= 1 && @@ -2499,7 +2542,7 @@ export class Dataset { }); } if (colKey.length < this.columns.length && this.columnHierarchyType === 'grid-tree') { - if (colKey[0] === this.colGrandTotalLabel) { + if (colKey[0] === this.colGrandTotalLabel || colKey[0] === this.groupedGrandTotalKey) { } else if ( ((this.totals?.column?.subTotalsDimensions && this.totals?.column?.subTotalsDimensions?.length >= 1 && diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index 50e61966f1..2caaf4de79 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -4362,7 +4362,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { if (path.colHeaderPaths.length) { path.colHeaderPaths.forEach(path => { if (path.dimensionKey) { - colKey.push(path.value); + colKey.push(path.dataValue ?? path.value); } }); } @@ -4378,7 +4378,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { if (path.rowHeaderPaths.length) { path.rowHeaderPaths.forEach(path => { if (path.dimensionKey) { - rowKey.push(path.value); + rowKey.push(path.dataValue ?? path.value); } }); } diff --git a/packages/vtable/src/ts-types/dataset/aggregation.ts b/packages/vtable/src/ts-types/dataset/aggregation.ts index e6e4df96d9..078a32ae4a 100644 --- a/packages/vtable/src/ts-types/dataset/aggregation.ts +++ b/packages/vtable/src/ts-types/dataset/aggregation.ts @@ -212,6 +212,7 @@ export class NoneAggregator extends Aggregator { export class CustomAggregator extends Aggregator { type: string = AggregationType.CUSTOM; isRecord?: boolean = true; + recalculateFromChildren?: boolean; declare field?: string; aggregationFun?: Function; values: (string | number)[] = []; @@ -222,6 +223,19 @@ export class CustomAggregator extends Aggregator { } push(record: any): void { if (record) { + if (this.recalculateFromChildren && record.isAggregator) { + if (this.isRecord && this.records) { + this.records.push(...record.records); + } + if (this.children) { + this.children.push(record); + } + if (this.field) { + this.values.push(...record.records.map((item: any) => item[this.field as string])); + } + this.clearCacheValue(); + return; + } if (this.isRecord && this.records) { if (record.isAggregator) { this.records.push(...record.records); @@ -290,6 +304,16 @@ export class CustomAggregator extends Aggregator { this.fieldValue = undefined; } recalculate() { + if (this.recalculateFromChildren && this.children?.length && this.field) { + this.values = []; + this.children.forEach(child => { + if (isValid(child.changedValue)) { + this.values.push(child.value()); + } else { + this.values.push(...child.records.map(record => record[this.field as string])); + } + }); + } this.fieldValue = undefined; this._formatedValue = undefined; // do nothing From 9c5858cd1b2392b9a1ceffecce0f21a359c2d016 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 1 Sep 2026 17:01:49 +0800 Subject: [PATCH 4/8] fix: complete grouped grand total edge cases Keep nested totals and chart ranges consistent after edits. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- docs/assets/option/en/table/pivotChart.md | 6 +- docs/assets/option/zh/table/pivotChart.md | 6 +- packages/vtable/__tests__/pivotChart.test.ts | 54 ++++ .../__tests__/pivotTable-analysis.test.ts | 215 ++++++++++++++ packages/vtable/src/PivotChart.ts | 4 +- packages/vtable/src/dataset/dataset.ts | 271 +++++++++++------- .../vtable/src/layout/pivot-header-layout.ts | 23 +- .../src/ts-types/dataset/aggregation.ts | 12 +- packages/vtable/src/ts-types/table-engine.ts | 2 + 9 files changed, 477 insertions(+), 116 deletions(-) diff --git a/docs/assets/option/en/table/pivotChart.md b/docs/assets/option/en/table/pivotChart.md index 785e1cc019..e4988ef5a1 100644 --- a/docs/assets/option/en/table/pivotChart.md +++ b/docs/assets/option/en/table/pivotChart.md @@ -49,6 +49,10 @@ The currently supported data formats are, taking the sales of large supermarkets ] ``` +## dataConfig(IPivotTableDataConfig) + +PivotChart supports the PivotTable data analysis configuration. When totals are configured, use `totals.row.grandTotalDimensions` or `totals.column.grandTotalDimensions` to create grouped grand totals by retained lower-level dimensions. + ## columnTree(Array) List header tree, type:`(IDimensionHeaderNode|IIndicatorHeaderNode)[]`. Among them, IDimensionHeaderNode refers to the dimension value node of the dimension non-indicator, and IIndicatorHeaderNode refers to the indicator name node. @@ -240,4 +244,4 @@ chartDimensionLinkage: { } } } -``` \ No newline at end of file +``` diff --git a/docs/assets/option/zh/table/pivotChart.md b/docs/assets/option/zh/table/pivotChart.md index 76ca917c79..5ee93965d8 100644 --- a/docs/assets/option/zh/table/pivotChart.md +++ b/docs/assets/option/zh/table/pivotChart.md @@ -49,6 +49,10 @@ ] ``` +## dataConfig(IPivotTableDataConfig) + +透视图支持复用透视表的数据分析配置。配置总计时可使用 `totals.row.grandTotalDimensions` 或 `totals.column.grandTotalDimensions`,按保留的下级维度生成分组总计。 + ## columnTree(Array) 列表头树,类型为:`(IDimensionHeaderNode|IIndicatorHeaderNode)[]`。其中 IDimensionHeaderNode 指的是维度非指标的维度值节点,IIndicatorHeaderNode 指的是指标名称节点。 @@ -247,4 +251,4 @@ chartDimensionLinkage: { } } } -``` \ No newline at end of file +``` diff --git a/packages/vtable/__tests__/pivotChart.test.ts b/packages/vtable/__tests__/pivotChart.test.ts index 729075a0b8..bba9a014b4 100644 --- a/packages/vtable/__tests__/pivotChart.test.ts +++ b/packages/vtable/__tests__/pivotChart.test.ts @@ -9249,6 +9249,60 @@ function createTable(containerDom) { const tableInstance = new VTable.PivotChart(option); return tableInstance; } + +describe('pivotChart grouped grand total collected values', () => { + test('collects chart ranges under grouped grand total paths', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotChart = new VTable.PivotChart({ + container: containerDom, + rows: ['organization', 'type'], + columns: [], + indicators: [ + { + indicatorKey: 'balance', + cellType: 'chart', + chartModule: 'vchart', + chartSpec: { + type: 'bar', + data: { + id: 'data' + }, + xField: 'type', + yField: 'balance' + } + } + ], + indicatorsAsCol: false, + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + row: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + const groupedKey = Object.keys(pivotChart.dataset.collectedValues.balance).find(key => + key.startsWith(String.fromCharCode(1)) + ); + + expect(pivotChart.dataset.collectedValues.balance[groupedKey]).toMatchObject({ + min: 400, + max: 400 + }); + pivotChart.release(); + }); +}); + describe('pivotChart init test', () => { const containerDom: HTMLElement = createDiv(); containerDom.style.position = 'relative'; diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index e8477f00a8..e039dbc6aa 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -646,6 +646,23 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('keeps a configured lower-level total when the first dimension has no values', () => { + const pivotTable = createPivotTable( + [ + { type: '银票', balance: 100 }, + { type: '商票', balance: 300 } + ], + { + rows: ['unused', 'type'] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(100); + pivotTable.release(); + }); + test('keeps column values matching the grand total label separate from grouped totals', () => { const containerDom: HTMLElement = createDiv(); containerDom.style.position = 'relative'; @@ -683,6 +700,62 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('does not classify retained-dimension records as totals when totals are hidden', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: ['organization', 'type'], + columns: [], + indicators: ['balance'], + records: [ + { organization: '公司一', type: '银票', balance: 100 }, + { type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + row: { + showGrandTotals: false, + grandTotalDimensions: ['type'] + } + } + } + }); + + expect(pivotTable.dataset.getAggregator(['银票'], [], 'balance').value()).toBe(300); + pivotTable.release(); + }); + + test('resolves column grouped totals independently for each row', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: ['region'], + columns: ['organization', 'type'], + indicators: ['balance'], + records: [ + { region: '华北', organization: '合计', type: '银票', balance: 100 }, + { region: '华东', organization: '公司一', type: '银票', balance: 300 } + ], + dataConfig: { + totals: { + column: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + + expect(pivotTable.dataset.getAggregator(['华东'], ['合计', '银票'], 'balance').value()).toBe(300); + pivotTable.release(); + }); + test('passes the custom aggregation function to grouped and overall totals', () => { const pivotTable = createPivotTable( [ @@ -714,6 +787,109 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('recalculates custom aggregation records after editing a multi-record cell', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '银票', balance: 200 }, + { organization: '公司二', type: '商票', balance: 300 } + ], + { + aggregationRules: [ + { + indicatorKey: 'balance', + field: 'balance', + aggregationType: VTable.TYPES.AggregationType.CUSTOM, + aggregationFun: (_values: number[], records: { balance: number }[]) => + records.reduce((sum, record) => sum + record.balance, 0) + } + ] + } + ); + const detailCell = findBodyCell(pivotTable, ['公司一', '银票'], 'normal'); + + pivotTable.changeCellValue(detailCell.col, detailCell.row, '500'); + + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(500); + expect(pivotTable.dataset.getAggregator(['合计', '小计'], [], 'balance').value()).toBe(800); + pivotTable.release(); + }); + + test('recalculates custom grouped total intersections after editing', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '500px'; + containerDom.style.height = '500px'; + const pivotTable = new PivotTable(containerDom, { + rows: ['organization', 'type'], + columns: ['category', 'channel'], + indicators: ['balance'], + records: [ + { organization: '公司一', type: '银票', category: '票据', channel: '线上', balance: 100 }, + { organization: '公司一', type: '银票', category: '票据', channel: '线上', balance: 200 } + ], + dataConfig: { + updateAggregationOnEditCell: true, + aggregationRules: [ + { + indicatorKey: 'balance', + field: 'balance', + aggregationType: VTable.TYPES.AggregationType.CUSTOM, + aggregationFun: (_values: number[], records: { balance: number }[]) => + records.reduce((sum, record) => sum + record.balance, 0) + } + ], + totals: { + row: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + }, + column: { + showGrandTotals: true, + grandTotalDimensions: ['channel'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + const findCell = (rowValues: string[], colValues: string[], role: 'normal' | 'grand-total') => { + for (let col = 0; col < pivotTable.colCount; col++) { + for (let row = 0; row < pivotTable.rowCount; row++) { + if (pivotTable.isHeader(col, row)) { + continue; + } + const paths = pivotTable.getCellHeaderPaths(col, row); + const currentRowValues = paths.rowHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const currentColValues = paths.colHeaderPaths + .filter(path => path.dimensionKey) + .map(path => path.value as string); + const roles = pivotTable.getCellPivotRole(col, row); + if ( + currentRowValues.join('|') === rowValues.join('|') && + currentColValues.join('|') === colValues.join('|') && + roles.rowRole === role && + roles.colRole === role + ) { + return { col, row }; + } + } + } + throw new Error(`Unable to find ${rowValues.join('/')} and ${colValues.join('/')}`); + }; + const detailCell = findCell(['公司一', '银票'], ['票据', '线上'], 'normal'); + + pivotTable.changeCellValue(detailCell.col, detailCell.row, '500'); + + const intersection = findCell(['合计', '银票'], ['合计', '线上'], 'grand-total'); + expect(pivotTable.getCellOriginValue(intersection.col, intersection.row)).toBe(500); + pivotTable.release(); + }); + test('keeps custom overall total records effective with grouped grand totals', () => { const pivotTable = createPivotTable([ { organization: '公司一', type: '银票', balance: 100 }, @@ -781,6 +957,7 @@ describe('pivotTable grouped grand total edge cases', () => { expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); expect(grandTotalNode.children[0].children.map(node => node.value)).toEqual(['银票', '商票']); expect(grandTotalNode.children[0].levelSpan).toBe(1); + expect(pivotTable.dataset.getAggregator(['合计', '票据'], [], 'balance').value()).toBe(400); expect(pivotTable.dataset.getAggregator(['合计', '票据', '银票'], [], 'balance').value()).toBe(100); const groupedCell = findBodyCell(pivotTable, ['合计', '票据', '银票'], 'grand-total'); expect(pivotTable.getCellOriginValue(groupedCell.col, groupedCell.row)).toBe(100); @@ -795,6 +972,44 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('sorts non-leaf grouped totals by their own aggregated values', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', category: '票据', type: '银票', balance: 100 }, + { organization: '公司二', category: '票据', type: '商票', balance: 600 }, + { organization: '公司三', category: '贷款', type: '信用贷', balance: 500 } + ], + { + rows: ['organization', 'category', 'type'], + grandTotalDimensions: ['category', 'type'], + sortRules: [ + { + sortField: 'category', + sortByIndicator: 'balance', + sortType: VTable.TYPES.SortType.DESC, + query: [] + } + ] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); + pivotTable.release(); + }); + + test('round-trips grouped total header paths when labels collide with data', () => { + const pivotTable = createPivotTable([ + { organization: '合计', type: '银票', balance: 100 }, + { organization: '公司二', type: '银票', balance: 300 } + ]); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', '银票'], 'grand-total'); + const paths = pivotTable.getCellHeaderPaths(groupedTotalCell.col, groupedTotalCell.row); + + expect(pivotTable.getCellAddressByHeaderPaths(paths)).toEqual(groupedTotalCell); + pivotTable.release(); + }); + test('orders tree grouped totals by retained dimensions and honors subtotal placement', () => { const pivotTable = createPivotTable( [ diff --git a/packages/vtable/src/PivotChart.ts b/packages/vtable/src/PivotChart.ts index feffb0cd0b..4f41a5024c 100644 --- a/packages/vtable/src/PivotChart.ts +++ b/packages/vtable/src/PivotChart.ts @@ -134,7 +134,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { this.setCustomStateNameToSpec(); this.internalProps.columnResizeType = options.resize?.columnResizeType ?? options.columnResizeType ?? 'column'; this.internalProps.rowResizeType = options.resize?.rowResizeType ?? options.rowResizeType ?? 'row'; - this.internalProps.dataConfig = { isPivotChart: true }; + this.internalProps.dataConfig = { ...cloneDeep(options.dataConfig), isPivotChart: true }; this.internalProps.columnWidthConfig = options.columnWidthConfig; this.internalProps.columnWidthConfigForRowHeader = options.columnWidthConfigForRowHeader; this._axes = isArray(options.axes) ? options.axes : []; @@ -356,7 +356,7 @@ export class PivotChart extends BaseTable implements PivotChartAPI { // 更新protectedSpace internalProps.columnResizeType = options.resize?.columnResizeType ?? options.columnResizeType ?? 'column'; internalProps.rowResizeType = options.resize?.rowResizeType ?? options.rowResizeType ?? 'row'; - internalProps.dataConfig = { isPivotChart: true }; + internalProps.dataConfig = { ...cloneDeep(options.dataConfig), isPivotChart: true }; this.internalProps.columnWidthConfig = options.columnWidthConfig; this.internalProps.columnWidthConfigForRowHeader = options.columnWidthConfigForRowHeader; this._axes = isArray(options.axes) ? options.axes : []; diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index 6c963a18e6..679beff3b4 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -333,7 +333,8 @@ export class Dataset { this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, this.rowSubTotalLabel, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.rows ); } else { this.rowHeaderTree = this.ArrToTree( @@ -349,7 +350,8 @@ export class Dataset { this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.showSubTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, - true + true, + this.rows ); } } @@ -385,7 +387,8 @@ export class Dataset { this.totals?.column?.showGrandTotalsOnLeft ?? false, this.totals?.column?.showSubTotalsOnLeft ?? false, this.totals?.column?.grandTotalDimensions, - false + false, + this.columns ); // } } @@ -560,6 +563,9 @@ export class Dataset { if ((this.filterRules?.length ?? 0) >= 1) { isNeedFilter = true; } + const records = Array.isArray(this.records) ? this.records : Object.values(this.records).flat(); + this.rowsHasValue = this.rows.map(row => records.some(record => record && row in record)); + this.columnsHasValue = this.columns.map(column => records.some(record => record && column in record)); //常规records是数组的情况 if (Array.isArray(this.records)) { if (!this.filteredRecords) { @@ -618,6 +624,93 @@ export class Dataset { * @param assignedIndicatorKey 指定要计算的指标key 外部用户 用指标做records的key 分别存储不同指标对应的数据时 会传入这个参数 * @returns */ + private collectValue(field: string, collectKeys: string, record: any) { + if (!this.collectedValues[field]) { + this.collectedValues[field] = {}; + } + if (!this.collectedValues[field][collectKeys]) { + if (this.collectValuesBy[field].sumBy) { + this.collectedValues[field][collectKeys] = {}; + } else if (this.collectValuesBy[field].range) { + this.collectedValues[field][collectKeys] = { + min: Number.MAX_SAFE_INTEGER, + max: Number.MIN_SAFE_INTEGER + }; + } else { + this.collectedValues[field][collectKeys] = []; + } + } + + if (this.collectValuesBy[field].sumBy) { + const sumByKeys: string = this.collectValuesBy[field].sumBy + ?.map(byField => record[byField]) + .join(this.stringJoinChar); + if (!(this.collectedValues[field][collectKeys] as any)[sumByKeys]) { + (this.collectedValues[field][collectKeys] as any)[sumByKeys] = new registeredAggregators[AggregationType.SUM]({ + key: field, + field, + isRecord: undefined, + needSplitPositiveAndNegative: this.needSplitPositiveAndNegative + }); + } + (this.collectedValues[field][collectKeys] as any)[sumByKeys].push(record); + } else if (this.collectValuesBy[field].range) { + const fieldRange = this.collectedValues[field][collectKeys] as { + max: number; + min: number; + }; + let max = Math.max(record[field], fieldRange.max); + let min = Math.min(record[field], fieldRange.min); + if (this.collectValuesBy[field].considerFields) { + for (const considerField of this.collectValuesBy[field].considerFields) { + if (record[considerField]) { + if (typeof record[considerField] === 'number') { + max = Math.max(record[considerField], max); + min = Math.min(record[considerField], min); + } else if (Array.isArray(record[considerField])) { + max = Math.max(...record[considerField], max); + min = Math.min(...record[considerField], min); + } + } + } + } + if (!isNaN(max)) { + fieldRange.max = max; + fieldRange.min = min; + } + } else { + const fieldValues = this.collectedValues[field][collectKeys] as Array; + if (fieldValues.indexOf(record[field]) === -1) { + fieldValues.push(record[field]); + } + } + } + + private getGroupedCollectedValueKeys(by: string[], record: any): string[] { + if (!(this.dataConfig as IPivotChartDataConfig)?.isPivotChart) { + return []; + } + const keys = new Set(); + [ + { dimensions: this.rows, totals: this.totals?.row }, + { dimensions: this.columns, totals: this.totals?.column } + ].forEach(({ dimensions, totals }) => { + if (!totals?.showGrandTotals) { + return; + } + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, totals.grandTotalDimensions); + if (dimensionIndexes.length && dimensionIndexes.every(index => by.indexOf(dimensions[index]) !== -1)) { + keys.add( + join( + [this.groupedGrandTotalKey, ...dimensionIndexes.map(index => record[dimensions[index]])], + this.stringJoinChar + ) + ); + } + }); + return Array.from(keys); + } + private processRecord(record: any, assignedIndicatorKey?: string) { //这个派生字段的计算位置有待确定,是否应该放到filter之前 this.derivedFieldRules?.forEach((derivedFieldRule: DerivedFieldRule, i: number) => { @@ -628,69 +721,11 @@ export class Dataset { //#region 按照collectValuesBy 收集维度值 for (const field in this.collectValuesBy) { if (isValid(record[field])) { - if (!this.collectedValues[field]) { - this.collectedValues[field] = {}; - } const collectKeys = this.collectValuesBy[field].by.map(byField => record[byField]).join(this.stringJoinChar); - if (!this.collectedValues[field][collectKeys]) { - if (this.collectValuesBy[field].sumBy) { - this.collectedValues[field][collectKeys] = {}; - } else if (this.collectValuesBy[field].range) { - this.collectedValues[field][collectKeys] = { - min: Number.MAX_SAFE_INTEGER, - max: Number.MIN_SAFE_INTEGER - }; - } else { - this.collectedValues[field][collectKeys] = []; - } - } - - if (this.collectValuesBy[field].sumBy) { - const sumByKeys: string = this.collectValuesBy[field].sumBy - ?.map(byField => record[byField]) - .join(this.stringJoinChar); - if (!(this.collectedValues[field][collectKeys] as any)[sumByKeys]) { - (this.collectedValues[field][collectKeys] as any)[sumByKeys] = new registeredAggregators[ - AggregationType.SUM - ]({ - key: field, - field: field, - isRecord: undefined, - needSplitPositiveAndNegative: this.needSplitPositiveAndNegative - }); - } - (this.collectedValues[field][collectKeys] as any)[sumByKeys].push(record); - } else if (this.collectValuesBy[field].range) { - const fieldRange = this.collectedValues[field][collectKeys] as { - max: number; - min: number; - }; - let max = Math.max(record[field], fieldRange.max); - let min = Math.min(record[field], fieldRange.min); - // 处理considerFields - if (this.collectValuesBy[field].considerFields) { - for (const considerField of this.collectValuesBy[field].considerFields) { - if (record[considerField]) { - if (typeof record[considerField] === 'number') { - max = Math.max(record[considerField], max); - min = Math.min(record[considerField], min); - } else if (Array.isArray(record[considerField])) { - max = Math.max(...record[considerField], max); - min = Math.min(...record[considerField], min); - } - } - } - } - if (!isNaN(max)) { - fieldRange.max = max; - fieldRange.min = min; - } - } else { - const fieldRange = this.collectedValues[field][collectKeys] as Array; - if (fieldRange.indexOf(record[field]) === -1) { - fieldRange.push(record[field]); - } - } + this.collectValue(field, collectKeys, record); + this.getGroupedCollectedValueKeys(this.collectValuesBy[field].by, record).forEach(groupedCollectKeys => { + this.collectValue(field, groupedCollectKeys, record); + }); } } //#endregion @@ -728,11 +763,10 @@ export class Dataset { } else { const rowKey: string[] = []; rowKeys.push({ rowKey, indicatorKey: assignedIndicatorKey }); - const customGroupedGrandTotalKey = this.getCustomGroupedGrandTotalKey( - record, - this.rows, - this.dataConfig?.totals?.row?.grandTotalDimensions - ); + const customGroupedGrandTotalKey = + this.dataConfig?.totals?.row?.showGrandTotals && this.rowsHasValue[0] + ? this.getCustomGroupedGrandTotalKey(record, this.rows, this.dataConfig?.totals?.row?.grandTotalDimensions) + : undefined; if (customGroupedGrandTotalKey) { rowKey.push(...customGroupedGrandTotalKey); isToTalRecord = true; @@ -804,11 +838,14 @@ export class Dataset { } else { const colKey: string[] = []; colKeys.push({ colKey, indicatorKey: assignedIndicatorKey }); - const customGroupedGrandTotalKey = this.getCustomGroupedGrandTotalKey( - record, - this.columns, - this.dataConfig?.totals?.column?.grandTotalDimensions - ); + const customGroupedGrandTotalKey = + this.dataConfig?.totals?.column?.showGrandTotals && this.columnsHasValue[0] + ? this.getCustomGroupedGrandTotalKey( + record, + this.columns, + this.dataConfig?.totals?.column?.grandTotalDimensions + ) + : undefined; if (customGroupedGrandTotalKey) { colKey.push(...customGroupedGrandTotalKey); isToTalRecord = true; @@ -1107,7 +1144,8 @@ export class Dataset { this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, this.rowSubTotalLabel, - this.totals?.row?.showSubTotalsOnTop ?? false + this.totals?.row?.showSubTotalsOnTop ?? false, + this.rows ); } else { this.rowHeaderTree = this.ArrToTree( @@ -1123,7 +1161,8 @@ export class Dataset { this.totals?.row?.showGrandTotalsOnTop ?? false, this.totals?.row?.showSubTotalsOnTop ?? false, this.totals?.row?.grandTotalDimensions, - true + true, + this.rows ); } } @@ -1142,7 +1181,8 @@ export class Dataset { this.totals?.column?.showGrandTotalsOnLeft ?? false, this.totals?.column?.showSubTotalsOnLeft ?? false, this.totals?.column?.grandTotalDimensions, - false + false, + this.columns ); } // this.rowKeysPath_FULL = this.TreeToArr( @@ -1297,7 +1337,8 @@ export class Dataset { !this.tree[flatRowKey] && this.getGrandTotalDimensionIndexes( this.rows.filter((_, index) => this.rowsHasValue[index]), - this.totals?.row?.grandTotalDimensions + this.totals?.row?.grandTotalDimensions, + this.rows ).length ) { const internalRowKey = rowKey.slice(); @@ -1310,10 +1351,11 @@ export class Dataset { if ( Array.isArray(colKey) && colKey[0] === this.colGrandTotalLabel && - !Object.keys(this.tree).some(rowTreeKey => this.tree[rowTreeKey]?.[flatColKey]) && + !this.tree[flatRowKey]?.[flatColKey] && this.getGrandTotalDimensionIndexes( this.columns.filter((_, index) => this.columnsHasValue[index]), - this.totals?.column?.grandTotalDimensions + this.totals?.column?.grandTotalDimensions, + this.columns ).length ) { const internalColKey = colKey.slice(); @@ -1521,17 +1563,17 @@ export class Dataset { if (sorter.sortRule?.sortByIndicator) { let aChanged = a; let bChanged = b; - if (!pathPrefix?.length && sorter.fieldIndex < fieldArr.length - 1) { + if (sorter.fieldIndex < fieldArr.length - 1) { aChanged = a.slice(0, sorter.fieldIndex + 1); - if (that.rowHierarchyType === 'grid' && isRow) { + if (!pathPrefix?.length && that.rowHierarchyType === 'grid' && isRow) { aChanged.push(that.rowSubTotalLabel); - } else if (!isRow) { + } else if (!pathPrefix?.length && !isRow) { aChanged.push(that.colSubTotalLabel); } bChanged = b.slice(0, sorter.fieldIndex + 1); - if (that.rowHierarchyType === 'grid' && isRow) { + if (!pathPrefix?.length && that.rowHierarchyType === 'grid' && isRow) { bChanged.push(that.rowSubTotalLabel); - } else if (!isRow) { + } else if (!pathPrefix?.length && !isRow) { bChanged.push(that.colSubTotalLabel); } } @@ -1746,7 +1788,8 @@ export class Dataset { colKey, that.columns.filter((_, index) => that.columnsHasValue[index]), that.totals?.column?.grandTotalDimensions, - that.colGrandTotalLabel + that.colGrandTotalLabel, + that.columns ); for (const flatColTotalKey of flatColTotalKeys) { if (this.totalRecordsTree?.[flatRowKey]?.[flatColTotalKey]) { @@ -1927,7 +1970,8 @@ export class Dataset { rowKey, that.rows.filter((_, index) => that.rowsHasValue[index]), that.totals?.row?.grandTotalDimensions, - that.rowGrandTotalLabel + that.rowGrandTotalLabel, + that.rows ); for (const flatRowTotalKey of flatRowTotalKeys) { if (!this.tree[flatRowTotalKey]) { @@ -2049,13 +2093,26 @@ export class Dataset { * @param arr * @returns */ - private getGrandTotalDimensionIndexes(dimensions: string[], grandTotalDimensions?: string[]) { + private getGrandTotalDimensionIndexes( + dimensions: string[], + grandTotalDimensions?: string[], + configuredDimensions: string[] = dimensions + ) { if (!grandTotalDimensions?.length) { return []; } return grandTotalDimensions - .map(dimension => dimensions.indexOf(dimension)) - .filter((index, position, indexes) => index > 0 && indexes.indexOf(index) === position) + .map(dimension => ({ + configuredIndex: configuredDimensions.indexOf(dimension), + activeIndex: dimensions.indexOf(dimension) + })) + .filter( + ({ configuredIndex, activeIndex }, position, indexes) => + configuredIndex > 0 && + activeIndex >= 0 && + indexes.findIndex(item => item.activeIndex === activeIndex) === position + ) + .map(({ activeIndex }) => activeIndex) .sort((a, b) => a - b); } @@ -2079,9 +2136,10 @@ export class Dataset { keys: string[][], dimensions: string[], grandTotalDimensions: string[] | undefined, - isRow: boolean = true + isRow: boolean = true, + configuredDimensions: string[] = dimensions ) { - const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions, configuredDimensions); const groupedPaths = new Map(); keys.forEach(key => { const path = dimensionIndexes.map(index => key[index]); @@ -2100,9 +2158,10 @@ export class Dataset { dimensionKey: string[], dimensions: string[], grandTotalDimensions: string[] | undefined, - grandTotalLabel: string + grandTotalLabel: string, + configuredDimensions: string[] = dimensions ) { - const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions, configuredDimensions); if (!dimensionIndexes.length) { return [grandTotalLabel]; } @@ -2111,7 +2170,7 @@ export class Dataset { ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex]) ]; return [ - join(groupedKey, this.stringJoinChar), + ...groupedKey.slice(1).map((_, index) => join(groupedKey.slice(0, index + 2), this.stringJoinChar)), join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar) ]; } @@ -2125,7 +2184,8 @@ export class Dataset { showGrandTotalsOnTop: boolean, grandTotalDimensions?: string[], subTotalLabel?: string, - showSubTotalsOnTop?: boolean + showSubTotalsOnTop?: boolean, + configuredRows: string[] = rows ) { /** * @@ -2198,7 +2258,7 @@ export class Dataset { arr.forEach(item => addList(item, false)); if (isGrandTotal) { - const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions); + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions, configuredRows); if (!grandTotalDimensionIndexes.length) { addList([grandTotalLabel], isGrandTotal); } else { @@ -2210,7 +2270,7 @@ export class Dataset { role: 'grand-total' }; const grandTotalMap = new Map(); - this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, true).forEach(list => { + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, true, configuredRows).forEach(list => { let parent = grandTotalNode; const path = [this.groupedGrandTotalKey]; grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { @@ -2268,7 +2328,8 @@ export class Dataset { showGrandTotalsOnTop: boolean, showSubTotalsOnTop: boolean, grandTotalDimensions?: string[], - isRow?: boolean + isRow?: boolean, + configuredRows: string[] = rows ) { /** * @@ -2376,7 +2437,7 @@ export class Dataset { } //最后将总计的节点加上 if (isGrandTotal && arr?.length) { - const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions); + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions, configuredRows); const createIndicatorChildren = () => indicators?.map(indicator => { if (typeof indicator === 'string') { @@ -2407,7 +2468,7 @@ export class Dataset { }; if (grandTotalDimensionIndexes.length) { const grandTotalMap = new Map(); - this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, isRow).forEach(list => { + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, isRow, configuredRows).forEach(list => { let parent = node; const path = [this.groupedGrandTotalKey]; grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { @@ -2694,7 +2755,8 @@ export class Dataset { rowKeyParts, this.rows.filter((_, index) => this.rowsHasValue[index]), this.totals.row.grandTotalDimensions, - this.rowGrandTotalLabel + this.rowGrandTotalLabel, + this.rows ); for (const rowGrandTotalKey of rowGrandTotalKeys) { if (rowAggregateKeys.indexOf(rowGrandTotalKey) === -1) { @@ -2724,7 +2786,8 @@ export class Dataset { colKeyParts, this.columns.filter((_, index) => this.columnsHasValue[index]), this.totals.column.grandTotalDimensions, - this.colGrandTotalLabel + this.colGrandTotalLabel, + this.columns ); for (const colGrandTotalKey of colGrandTotalKeys) { if (colAggregateKeys.indexOf(colGrandTotalKey) === -1) { diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index 2caaf4de79..19b587b9bc 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -3423,10 +3423,14 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { const isMatch = (!isValid(currentPath.indicatorKey) && dimension.dimensionKey === currentPath.dimensionKey && - dimension.value === currentPath.value) || + (isValid(currentPath.dataValue) + ? ((dimension as any).dataValue ?? dimension.value) === currentPath.dataValue + : dimension.value === currentPath.value)) || (isValid(currentPath.indicatorKey) && dimension.indicatorKey === currentPath.indicatorKey && - ((isValid(dimension.value) && isValid(currentPath.value) && currentPath.value === dimension.value) || + ((isValid(currentPath.dataValue) && + ((dimension as any).dataValue ?? dimension.value) === currentPath.dataValue) || + (isValid(dimension.value) && isValid(currentPath.value) && currentPath.value === dimension.value) || !isValid(dimension.value) || !isValid(currentPath.value))); @@ -3625,7 +3629,9 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { .filter((hd: HeaderData) => { return ( (hd?.field === rowDimension.dimensionKey || hd?.field === rowDimension.indicatorKey) && - hd?.title === rowDimension.value + (isValid(rowDimension.dataValue) + ? ((hd as any).dataValue ?? hd.title) === rowDimension.dataValue + : hd?.title === rowDimension.value) ); }) .map((hd: HeaderData) => { @@ -3643,7 +3649,12 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { fullCellIds.every(id => { const curHd = this._headerObjectMap[id]; return rowHeaderPaths.find(rowDimensionPath => { - return rowDimensionPath.dimensionKey === curHd.field && rowDimensionPath.value === curHd.title; + return ( + rowDimensionPath.dimensionKey === curHd.field && + (isValid(rowDimensionPath.dataValue) + ? ((curHd as any).dataValue ?? curHd.title) === rowDimensionPath.dataValue + : rowDimensionPath.value === curHd.title) + ); }); }) ); @@ -4258,7 +4269,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { this.dataset.collectedValues[key]?.[ path .map(pathObj => { - return pathObj.value; + return pathObj.dataValue ?? pathObj.value; }) .join(this.dataset.stringJoinChar) ]; @@ -4291,7 +4302,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { this.dataset.collectedValues[key]?.[ path .map(pathObj => { - return pathObj.value; + return pathObj.dataValue ?? pathObj.value; }) .join(this.dataset.stringJoinChar) ]; diff --git a/packages/vtable/src/ts-types/dataset/aggregation.ts b/packages/vtable/src/ts-types/dataset/aggregation.ts index 078a32ae4a..f1aad2d0ae 100644 --- a/packages/vtable/src/ts-types/dataset/aggregation.ts +++ b/packages/vtable/src/ts-types/dataset/aggregation.ts @@ -306,11 +306,19 @@ export class CustomAggregator extends Aggregator { recalculate() { if (this.recalculateFromChildren && this.children?.length && this.field) { this.values = []; + this.records = []; this.children.forEach(child => { if (isValid(child.changedValue)) { - this.values.push(child.value()); + const value = child.value(); + this.values.push(value); + this.records.push({ [this.field as string]: value }); + } else if (child instanceof CustomAggregator && child.recalculateFromChildren) { + this.values.push(...child.values); + this.records.push(...child.records); } else { - this.values.push(...child.records.map(record => record[this.field as string])); + const records = child.records; + this.values.push(...records.map(record => record[this.field as string])); + this.records.push(...records); } }); } diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index 6e1c48afc4..0bc3afebb5 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -631,6 +631,8 @@ export interface PivotChartConstructorOptions extends BaseTableConstructorOption * 数据集合, 平坦数据集合。另外一种特殊方式是传入分组后的数据,分组依据为指标 */ records?: any[] | Record; + /** 数据分析相关配置 */ + dataConfig?: IPivotTableDataConfig; /** * @deprecated 请使用resize.columnResizeType */ From a0fdfd90f02d26669b27e089ca5b8b5544862913 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 1 Sep 2026 18:19:20 +0800 Subject: [PATCH 5/8] fix: preserve data config in data path lookup Keep grouped grand total paths available to PivotChart data lookup. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../layout/get-data-cell-path.test.ts | 45 +++++++++++++++++++ .../src/tools/get-data-path/create-dataset.ts | 6 +-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/vtable/__tests__/layout/get-data-cell-path.test.ts b/packages/vtable/__tests__/layout/get-data-cell-path.test.ts index beeac72e7a..2fff7b8ed2 100644 --- a/packages/vtable/__tests__/layout/get-data-cell-path.test.ts +++ b/packages/vtable/__tests__/layout/get-data-cell-path.test.ts @@ -7184,4 +7184,49 @@ describe('get-data-cell-path test', () => { cellLocation: 'body' }); }); + + test('get-data-cell-path returns the grouped grand total path', () => { + const data = { organization: '公司一', type: '银票', balance: 100 }; + const path = getDataCellPath( + { + rows: ['organization', 'type'], + columns: [], + indicators: [ + { + indicatorKey: 'balance', + cellType: 'chart', + chartModule: 'vchart', + chartSpec: { + type: 'bar', + data: { + id: 'data' + }, + xField: 'type', + yField: 'balance' + } + } + ], + indicatorsAsCol: false, + records: [data], + dataConfig: { + totals: { + row: { + showGrandTotals: true, + showGrandTotalsOnTop: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }, + data + ); + + expect(path?.rowHeaderPaths[0]).toMatchObject({ + dimensionKey: 'organization', + value: '合计' + }); + expect(path?.rowHeaderPaths[0].dataValue).toContain('grouped_grand_total'); + }); }); diff --git a/packages/vtable/src/tools/get-data-path/create-dataset.ts b/packages/vtable/src/tools/get-data-path/create-dataset.ts index 30bf7fc551..3a5ed34d24 100644 --- a/packages/vtable/src/tools/get-data-path/create-dataset.ts +++ b/packages/vtable/src/tools/get-data-path/create-dataset.ts @@ -15,7 +15,7 @@ import type { IChartColumnIndicator } from '../../ts-types/pivot-table/indicator export function createDataset(options: PivotChartConstructorOptions) { const layoutNodeId = { seqId: 0 }; - const dataConfig: IPivotChartDataConfig = { isPivotChart: true }; + const dataConfig: IPivotChartDataConfig = { ...options.dataConfig, isPivotChart: true }; let columnDimensionTree; let rowDimensionTree; let columnTree; @@ -32,7 +32,7 @@ export function createDataset(options: PivotChartConstructorOptions) { } rowDimensionTree = new DimensionTree((options.rowTree as ITreeLayoutHeadNode[]) ?? [], layoutNodeId); } - const rowKeys = rowDimensionTree.dimensionKeys?.count + const rowKeys = rowDimensionTree?.dimensionKeys?.count ? rowDimensionTree.dimensionKeys.valueArr() : options.rows?.reduce((keys, rowObj) => { if (typeof rowObj === 'string') { @@ -42,7 +42,7 @@ export function createDataset(options: PivotChartConstructorOptions) { } return keys; }, []) ?? []; - const columnKeys = columnDimensionTree.dimensionKeys?.count + const columnKeys = columnDimensionTree?.dimensionKeys?.count ? columnDimensionTree.dimensionKeys.valueArr() : options.columns?.reduce((keys, columnObj) => { if (typeof columnObj === 'string') { From 9047f5f5c0c64e671b5b7cb5352cb539b5c84e9c Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 2 Sep 2026 15:46:22 +0800 Subject: [PATCH 6/8] fix: complete grouped grand total chart paths Preserve chart ranges and header spans for grouped grand totals. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/__tests__/pivotChart.test.ts | 9 ++++++++- packages/vtable/__tests__/pivotTable-analysis.test.ts | 1 + packages/vtable/src/dataset/dataset.ts | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/vtable/__tests__/pivotChart.test.ts b/packages/vtable/__tests__/pivotChart.test.ts index bba9a014b4..a21afd8943 100644 --- a/packages/vtable/__tests__/pivotChart.test.ts +++ b/packages/vtable/__tests__/pivotChart.test.ts @@ -9292,13 +9292,20 @@ describe('pivotChart grouped grand total collected values', () => { } }); const groupedKey = Object.keys(pivotChart.dataset.collectedValues.balance).find(key => - key.startsWith(String.fromCharCode(1)) + key.startsWith(`${String.fromCharCode(1)}vtable_grouped_grand_total${pivotChart.dataset.stringJoinChar}`) ); + const groupedSubtotalKey = `${String.fromCharCode(1)}vtable_grouped_grand_total${ + pivotChart.dataset.stringJoinChar + }${String.fromCharCode(1)}vtable_grouped_grand_total_subtotal`; expect(pivotChart.dataset.collectedValues.balance[groupedKey]).toMatchObject({ min: 400, max: 400 }); + expect(pivotChart.dataset.collectedValues.balance[groupedSubtotalKey]).toMatchObject({ + min: 400, + max: 400 + }); pivotChart.release(); }); }); diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index e039dbc6aa..4b70a45a99 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -658,6 +658,7 @@ describe('pivotTable grouped grand total edge cases', () => { ); const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + expect(grandTotalNode.levelSpan).toBe(1); expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '小计']); expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(100); pivotTable.release(); diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index 679beff3b4..69da760950 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -706,6 +706,7 @@ export class Dataset { this.stringJoinChar ) ); + keys.add(join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar)); } }); return Array.from(keys); @@ -2462,7 +2463,7 @@ export class Dataset { value: grandTotalLabel, // getId(item?.id, 1), dataValue: grandTotalDimensionIndexes.length ? this.groupedGrandTotalKey : undefined, dimensionKey: rows[0], - levelSpan: grandTotalDimensionIndexes[0] ?? subTotalFlags.length, + levelSpan: Math.max(1, grandTotalDimensionIndexes[0] ?? subTotalFlags.length), children: [], role: 'grand-total' }; From 4d0f7b5487c4118ce978f71d0bf06e79729efbeb Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 2 Sep 2026 18:13:12 +0800 Subject: [PATCH 7/8] fix: handle grouped grand total review regressions Use filtered records and ordered internal paths so grouped totals retain correct dimensions, chart ranges, custom values, and tree cell lookups. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/__tests__/pivotChart.test.ts | 9 ++-- .../__tests__/pivotTable-analysis.test.ts | 28 +++++++++++- packages/vtable/src/dataset/dataset.ts | 45 ++++++++++++------- .../vtable/src/layout/pivot-header-layout.ts | 26 ++++++----- 4 files changed, 74 insertions(+), 34 deletions(-) diff --git a/packages/vtable/__tests__/pivotChart.test.ts b/packages/vtable/__tests__/pivotChart.test.ts index a21afd8943..f61199a49c 100644 --- a/packages/vtable/__tests__/pivotChart.test.ts +++ b/packages/vtable/__tests__/pivotChart.test.ts @@ -9251,7 +9251,7 @@ function createTable(containerDom) { } describe('pivotChart grouped grand total collected values', () => { - test('collects chart ranges under grouped grand total paths', () => { + test('collects chart ranges under grouped grand total paths while ignoring external total records', () => { const containerDom: HTMLElement = createDiv(); containerDom.style.position = 'relative'; containerDom.style.width = '500px'; @@ -9278,7 +9278,8 @@ describe('pivotChart grouped grand total collected values', () => { indicatorsAsCol: false, records: [ { organization: '公司一', type: '银票', balance: 100 }, - { organization: '公司二', type: '银票', balance: 300 } + { organization: '公司二', type: '银票', balance: 300 }, + { type: '银票', balance: 999 } ], dataConfig: { totals: { @@ -9291,9 +9292,7 @@ describe('pivotChart grouped grand total collected values', () => { } } }); - const groupedKey = Object.keys(pivotChart.dataset.collectedValues.balance).find(key => - key.startsWith(`${String.fromCharCode(1)}vtable_grouped_grand_total${pivotChart.dataset.stringJoinChar}`) - ); + const groupedKey = `${String.fromCharCode(1)}vtable_grouped_grand_total${pivotChart.dataset.stringJoinChar}银票`; const groupedSubtotalKey = `${String.fromCharCode(1)}vtable_grouped_grand_total${ pivotChart.dataset.stringJoinChar }${String.fromCharCode(1)}vtable_grouped_grand_total_subtotal`; diff --git a/packages/vtable/__tests__/pivotTable-analysis.test.ts b/packages/vtable/__tests__/pivotTable-analysis.test.ts index 4b70a45a99..95cf45e311 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -547,6 +547,7 @@ describe('pivotTable grouped grand total edge cases', () => { showSubTotalsOnTreeNode?: boolean; aggregationRules?: any[]; sortRules?: any[]; + filterRules?: any[]; } = {} ) { const containerDom: HTMLElement = createDiv(); @@ -565,6 +566,7 @@ describe('pivotTable grouped grand total edge cases', () => { updateAggregationOnEditCell: true, aggregationRules: options.aggregationRules, sortRules: options.sortRules, + filterRules: options.filterRules, totals: { row: { showGrandTotals: true, @@ -664,6 +666,24 @@ describe('pivotTable grouped grand total edge cases', () => { pivotTable.release(); }); + test('only uses filtered records to determine active grouped total dimensions', () => { + const pivotTable = createPivotTable( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { type: '商票', balance: 300 } + ], + { + filterRules: [{ filterFunc: record => record.organization !== '公司一' }] + } + ); + const grandTotalNode = pivotTable.dataset.rowHeaderTree.find(node => node.role === 'grand-total'); + + expect(grandTotalNode.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['商票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '商票'], [], 'balance').value()).toBe(300); + pivotTable.release(); + }); + test('keeps column values matching the grand total label separate from grouped totals', () => { const containerDom: HTMLElement = createDiv(); containerDom.style.position = 'relative'; @@ -944,7 +964,8 @@ describe('pivotTable grouped grand total edge cases', () => { [ { organization: '公司一', category: '票据', type: '银票', balance: 100 }, { organization: '公司二', category: '票据', type: '商票', balance: 300 }, - { organization: '公司三', category: '贷款', type: '信用贷', balance: 200 } + { organization: '公司三', category: '贷款', type: '信用贷', balance: 200 }, + { category: '票据', balance: 999 } ], { rows: ['organization', 'category', 'type'], @@ -958,7 +979,7 @@ describe('pivotTable grouped grand total edge cases', () => { expect(grandTotalNode.children.map(node => node.value)).toEqual(['票据', '贷款', '小计']); expect(grandTotalNode.children[0].children.map(node => node.value)).toEqual(['银票', '商票']); expect(grandTotalNode.children[0].levelSpan).toBe(1); - expect(pivotTable.dataset.getAggregator(['合计', '票据'], [], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator(['合计', '票据'], [], 'balance').value()).toBe(999); expect(pivotTable.dataset.getAggregator(['合计', '票据', '银票'], [], 'balance').value()).toBe(100); const groupedCell = findBodyCell(pivotTable, ['合计', '票据', '银票'], 'grand-total'); expect(pivotTable.getCellOriginValue(groupedCell.col, groupedCell.row)).toBe(100); @@ -1031,6 +1052,9 @@ describe('pivotTable grouped grand total edge cases', () => { expect(grandTotalNode.children.map(node => node.value)).toEqual(['小计', 'B', 'A']); expect(pivotTable.dataset.getAggregator(['合计', 'B'], [], 'balance').value()).toBe(200); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', 'B'], 'grand-total'); + const paths = pivotTable.getCellHeaderPaths(groupedTotalCell.col, groupedTotalCell.row); + expect(pivotTable.getCellAddressByHeaderPaths(paths)).toEqual(groupedTotalCell); pivotTable.release(); }); }); diff --git a/packages/vtable/src/dataset/dataset.ts b/packages/vtable/src/dataset/dataset.ts index 69da760950..bafb5c4cb1 100644 --- a/packages/vtable/src/dataset/dataset.ts +++ b/packages/vtable/src/dataset/dataset.ts @@ -563,7 +563,9 @@ export class Dataset { if ((this.filterRules?.length ?? 0) >= 1) { isNeedFilter = true; } - const records = Array.isArray(this.records) ? this.records : Object.values(this.records).flat(); + const records = (Array.isArray(this.records) ? this.records : Object.values(this.records).flat()).filter( + record => !isNeedFilter || this.filterRecord(record) + ); this.rowsHasValue = this.rows.map(row => records.some(record => record && row in record)); this.columnsHasValue = this.columns.map(column => records.some(record => record && column in record)); //常规records是数组的情况 @@ -719,18 +721,6 @@ export class Dataset { record[derivedFieldRule.fieldName] = derivedFieldRule.derivedFunc(record); } }); - //#region 按照collectValuesBy 收集维度值 - for (const field in this.collectValuesBy) { - if (isValid(record[field])) { - const collectKeys = this.collectValuesBy[field].by.map(byField => record[byField]).join(this.stringJoinChar); - this.collectValue(field, collectKeys, record); - this.getGroupedCollectedValueKeys(this.collectValuesBy[field].by, record).forEach(groupedCollectKeys => { - this.collectValue(field, groupedCollectKeys, record); - }); - } - } - //#endregion - let isToTalRecord = false; //#region 收集rowKey colKey // 原先的逻辑不关心customRowTree 只是根据rows 从record上收集维度path。现在考虑了rowTree和colTree的传入,需要依据colTree的真实定义的path来给数据做对应关系。 @@ -892,6 +882,19 @@ export class Dataset { } } //#endregion + //#region 按照collectValuesBy 收集维度值 + if (!isToTalRecord) { + for (const field in this.collectValuesBy) { + if (isValid(record[field])) { + const collectKeys = this.collectValuesBy[field].by.map(byField => record[byField]).join(this.stringJoinChar); + this.collectValue(field, collectKeys, record); + this.getGroupedCollectedValueKeys(this.collectValuesBy[field].by, record).forEach(groupedCollectKeys => { + this.collectValue(field, groupedCollectKeys, record); + }); + } + } + } + //#endregion //#region 对path的数组 rowKeys和colKeys 做双重循环 for (let row_i = 0; row_i < rowKeys.length; row_i++) { const rowKey = rowKeys[row_i].rowKey; @@ -2125,12 +2128,24 @@ export class Dataset { const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); if ( !dimensionIndexes.length || - !dimensionIndexes.every(index => dimensions[index] in record) || dimensions.some((dimension, index) => dimensionIndexes.indexOf(index) === -1 && dimension in record) ) { return undefined; } - return [this.groupedGrandTotalKey, ...dimensionIndexes.map(index => record[dimensions[index]])]; + const prefixIndexes: number[] = []; + for (const index of dimensionIndexes) { + if (!(dimensions[index] in record)) { + break; + } + prefixIndexes.push(index); + } + if ( + !prefixIndexes.length || + dimensionIndexes.slice(prefixIndexes.length).some(index => dimensions[index] in record) + ) { + return undefined; + } + return [this.groupedGrandTotalKey, ...prefixIndexes.map(index => record[dimensions[index]])]; } private getGroupedGrandTotalPaths( diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index 19b587b9bc..37b07a80fc 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -3630,7 +3630,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { return ( (hd?.field === rowDimension.dimensionKey || hd?.field === rowDimension.indicatorKey) && (isValid(rowDimension.dataValue) - ? ((hd as any).dataValue ?? hd.title) === rowDimension.dataValue + ? ((hd as any).dataValue ?? (hd as any).define?.dataValue ?? hd.title) === rowDimension.dataValue : hd?.title === rowDimension.value) ); }) @@ -3646,20 +3646,22 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { const fullCellIds = this.findFullCellIds(pathIds); return ( fullCellIds.length === rowHeaderPaths.length && - fullCellIds.every(id => { + fullCellIds.every((id, index) => { const curHd = this._headerObjectMap[id]; - return rowHeaderPaths.find(rowDimensionPath => { - return ( - rowDimensionPath.dimensionKey === curHd.field && - (isValid(rowDimensionPath.dataValue) - ? ((curHd as any).dataValue ?? curHd.title) === rowDimensionPath.dataValue - : rowDimensionPath.value === curHd.title) - ); - }); + const rowDimensionPath = rowHeaderPaths[index]; + return ( + rowDimensionPath.dimensionKey === curHd.field && + (isValid(rowDimensionPath.dataValue) + ? ((curHd as any).dataValue ?? (curHd as any).define?.dataValue ?? curHd.title) === + rowDimensionPath.dataValue + : rowDimensionPath.value === curHd.title) + ); }) ); }); - row = this._rowHeaderCellIds.indexOf(findedCellIdPath) + this.columnHeaderLevelCount; + if (findedCellIdPath) { + row = this._rowHeaderCellIds.indexOf(findedCellIdPath) + this.columnHeaderLevelCount; + } } else { rowDimensionFinded = this.matchDimensionPath(rowHeaderPaths, this.rowTree, needLowestLevel_rowPaths, true) as | ITreeLayoutHeadNode @@ -3674,7 +3676,7 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { } } // 通过dimension获取col和row - if (rowDimensionFinded || forceBody) { + if (rowDimensionFinded || (forceBody && !isValid(row))) { row = this.columnHeaderLevelCount; const { startInTotal, afterSpanLevel } = (rowDimensionFinded as ITreeLayoutHeadNode) ?? defaultDimension; row += startInTotal ?? 0; From 8e5a5341d7d34f28c2a68a144ec8cace8cafda47 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 2 Sep 2026 19:18:16 +0800 Subject: [PATCH 8/8] fix: preserve tree header path lookup compatibility Retain legacy value-only tree matching while using ordered internal values for grouped grand total header paths. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../vtable/src/layout/pivot-header-layout.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/vtable/src/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index 37b07a80fc..4220b87f9b 100644 --- a/packages/vtable/src/layout/pivot-header-layout.ts +++ b/packages/vtable/src/layout/pivot-header-layout.ts @@ -3644,19 +3644,19 @@ export class PivotHeaderLayoutMap implements LayoutMapAPI { // 从上述过程中找到的pathCellIds中找到正确匹配完整路径rowHeaderPaths的一个 然后计算row行号 const findedCellIdPath = findedCellIdPaths.find(pathIds => { const fullCellIds = this.findFullCellIds(pathIds); + const matchHeaderPath = (curHd: HeaderData, rowDimensionPath: IDimensionInfo) => + rowDimensionPath.dimensionKey === curHd.field && + (isValid(rowDimensionPath.dataValue) + ? ((curHd as any).dataValue ?? (curHd as any).define?.dataValue ?? curHd.title) === + rowDimensionPath.dataValue + : rowDimensionPath.value === curHd.title); return ( fullCellIds.length === rowHeaderPaths.length && - fullCellIds.every((id, index) => { - const curHd = this._headerObjectMap[id]; - const rowDimensionPath = rowHeaderPaths[index]; - return ( - rowDimensionPath.dimensionKey === curHd.field && - (isValid(rowDimensionPath.dataValue) - ? ((curHd as any).dataValue ?? (curHd as any).define?.dataValue ?? curHd.title) === - rowDimensionPath.dataValue - : rowDimensionPath.value === curHd.title) - ); - }) + (rowHeaderPaths.some(rowDimensionPath => isValid(rowDimensionPath.dataValue)) + ? fullCellIds.every((id, index) => matchHeaderPath(this._headerObjectMap[id], rowHeaderPaths[index])) + : fullCellIds.every(id => + rowHeaderPaths.some(rowDimensionPath => matchHeaderPath(this._headerObjectMap[id], rowDimensionPath)) + )) ); }); if (findedCellIdPath) {