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..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 -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. 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 @@ -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..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 来设置行列维度的小计总计。该示例汇总总显示到底部,也可以通过 total.showGrandTotalsOnTop 来显示到顶部。[配置参考](../../option/PivotTable#dataConfig.totals) +透视分析表格通过 `dataConfig.totals` 配置行列维度的小计和总计。本示例中,行方向保持原有的小计和总计效果;列方向通过 `grandTotalDimensions: ['Segment']`,让列总计在 `Segment` 维度下分组展示,并追加一个包含全部分组的整体小计。行总计默认显示在底部,可通过 `showGrandTotalsOnTop` 调整到顶部;列总计默认显示在右侧,可通过 `showGrandTotalsOnLeft` 调整到左侧。[配置参考](../../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..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,6 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], + grandTotalDimensions: ['province'], // Group the grand total by province 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, 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 #### 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..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,6 +82,7 @@ dataConfig: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['province'], + grandTotalDimensions: ['province'], // 总计区域继续按 province 分组,并追加整体小计 grandTotalLabel: '行总计', subTotalLabel: '小计', showGrandTotalsOnTop: true, //汇总值显示在上 @@ -99,6 +100,8 @@ dataConfig: { }, ``` +`grandTotalDimensions` 用于在总计区域中保留下级维度。比如上述行维度为 `['region', 'province']` 时,将行总计配置为 `grandTotalDimensions: ['province']`,会在“行总计”下按省份分别显示汇总值,并追加一行整体“小计”。该配置也可用于列总计,只支持当前行或列方向首层之后的维度;不配置时仍显示原有的单一总计。 + 具体示例:https://visactor.io/vtable/demo/data-analysis/pivot-analysis-total #### 自定义汇总数据 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/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/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/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__/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/__tests__/pivotChart.test.ts b/packages/vtable/__tests__/pivotChart.test.ts index 729075a0b8..f61199a49c 100644 --- a/packages/vtable/__tests__/pivotChart.test.ts +++ b/packages/vtable/__tests__/pivotChart.test.ts @@ -9249,6 +9249,66 @@ 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 while ignoring external total records', () => { + 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 }, + { type: '银票', balance: 999 } + ], + dataConfig: { + totals: { + row: { + showGrandTotals: true, + grandTotalDimensions: ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计' + } + } + } + }); + 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`; + + expect(pivotChart.dataset.collectedValues.balance[groupedKey]).toMatchObject({ + min: 400, + max: 400 + }); + expect(pivotChart.dataset.collectedValues.balance[groupedSubtotalKey]).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 ffff4f483a..95cf45e311 100644 --- a/packages/vtable/__tests__/pivotTable-analysis.test.ts +++ b/packages/vtable/__tests__/pivotTable-analysis.test.ts @@ -378,3 +378,683 @@ 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: '小计' + } + } + } + }); + + 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(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(['银票', '信用证', '商票', '小计']); + }); + + test('recalculates grouped and overall grand totals after editing', () => { + const detailCell = findBodyCell(['公司一', '银票'], [], 'normal'); + pivotTable.changeCellValue(detailCell.col, detailCell.row, '150'); + const groupedTotalCell = findBodyCell(['合计', '银票'], [], 'grand-total'); + const overallTotalCell = findBodyCell(['合计', '小计'], [], 'grand-total'); + + 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(); + }); +}); + +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: { + updateAggregationOnEditCell: true, + 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(pivotTable.dataset.getAggregator([], ['合计', '银票'], 'balance').value()).toBe(400); + expect(pivotTable.dataset.getAggregator([], ['合计', '小计'], 'balance').value()).toBe(1000); + + 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(); + }); +}); + +describe('pivotTable grouped grand total edge cases', () => { + function createPivotTable( + records: any[], + options: { + rows?: string[]; + rowHierarchyType?: 'grid' | 'tree' | 'grid-tree'; + grandTotalDimensions?: string[]; + showSubTotalsOnTop?: boolean; + showSubTotalsOnTreeNode?: boolean; + aggregationRules?: any[]; + sortRules?: any[]; + filterRules?: 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, + rowExpandLevel: 3, + records, + dataConfig: { + updateAggregationOnEditCell: true, + aggregationRules: options.aggregationRules, + sortRules: options.sortRules, + filterRules: options.filterRules, + totals: { + row: { + showGrandTotals: true, + showSubTotals: false, + grandTotalDimensions: options.grandTotalDimensions ?? ['type'], + grandTotalLabel: '合计', + subTotalLabel: '小计', + 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 }, + { 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); + const groupedTotalCell = findBodyCell(pivotTable, ['合计', '小计'], 'grand-total'); + expect(pivotTable.getCellOriginValue(groupedTotalCell.col, groupedTotalCell.row)).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 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.levelSpan).toBe(1); + expect(grandTotalNode.children.map(node => node.value)).toEqual(['银票', '商票', '小计']); + expect(pivotTable.dataset.getAggregator(['合计', '银票'], [], 'balance').value()).toBe(100); + 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'; + 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('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( + [ + { organization: '公司一', type: '银票', balance: 100 }, + { organization: '公司一', type: '银票', balance: 200 }, + { organization: '公司二', type: '商票', balance: 300 }, + { organization: '公司二', type: '商票', balance: 500 } + ], + { + 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(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(); + }); + + 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 }, + { 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('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 }, + { category: '票据', balance: 999 } + ], + { + 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(999); + 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('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( + [ + { 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); + 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/PivotChart.ts b/packages/vtable/src/PivotChart.ts index dce0beee77..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 : []; @@ -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 a2f5c516e0..bafb5c4cb1 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[] = []; @@ -328,7 +330,11 @@ 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, + this.totals?.row?.showSubTotalsOnTop ?? false, + this.rows ); } else { this.rowHeaderTree = this.ArrToTree( @@ -342,7 +348,10 @@ 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, + true, + this.rows ); } } @@ -376,7 +385,10 @@ 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, + false, + this.columns ); // } } @@ -551,6 +563,11 @@ export class Dataset { if ((this.filterRules?.length ?? 0) >= 1) { isNeedFilter = true; } + 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是数组的情况 if (Array.isArray(this.records)) { if (!this.filteredRecords) { @@ -609,83 +626,101 @@ export class Dataset { * @param assignedIndicatorKey 指定要计算的指标key 外部用户 用指标做records的key 分别存储不同指标对应的数据时 会传入这个参数 * @returns */ - private processRecord(record: any, assignedIndicatorKey?: string) { - //这个派生字段的计算位置有待确定,是否应该放到filter之前 - this.derivedFieldRules?.forEach((derivedFieldRule: DerivedFieldRule, i: number) => { - if (derivedFieldRule.fieldName && derivedFieldRule.derivedFunc) { - record[derivedFieldRule.fieldName] = derivedFieldRule.derivedFunc(record); + 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] = []; } - }); - //#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 (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 fieldRange = this.collectedValues[field][collectKeys] as Array; - if (fieldRange.indexOf(record[field]) === -1) { - fieldRange.push(record[field]); - } } } + 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]); + } } - //#endregion + } + 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 + ) + ); + keys.add(join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar)); + } + }); + return Array.from(keys); + } + + private processRecord(record: any, assignedIndicatorKey?: string) { + //这个派生字段的计算位置有待确定,是否应该放到filter之前 + this.derivedFieldRules?.forEach((derivedFieldRule: DerivedFieldRule, i: number) => { + if (derivedFieldRule.fieldName && derivedFieldRule.derivedFunc) { + record[derivedFieldRule.fieldName] = derivedFieldRule.derivedFunc(record); + } + }); let isToTalRecord = false; //#region 收集rowKey colKey // 原先的逻辑不关心customRowTree 只是根据rows 从record上收集维度path。现在考虑了rowTree和colTree的传入,需要依据colTree的真实定义的path来给数据做对应关系。 @@ -719,35 +754,50 @@ 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; - }) - ) { - 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.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; + } 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; } } } @@ -779,39 +829,72 @@ 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; - }) - ) { - 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; + 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; + } 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.dataConfig?.totals?.column?.showGrandTotals && + n === 0 && + !this.columns.find((ck: string) => { + // 判断没有其他字段在record中 + return ck in record; + }) + ) { + 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; + } } } } } //#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; @@ -1062,7 +1145,11 @@ 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, + this.totals?.row?.showSubTotalsOnTop ?? false, + this.rows ); } else { this.rowHeaderTree = this.ArrToTree( @@ -1076,7 +1163,10 @@ 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, + true, + this.rows ); } } @@ -1093,7 +1183,10 @@ 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, + false, + this.columns ); } // this.rowKeysPath_FULL = this.TreeToArr( @@ -1185,8 +1278,8 @@ export class Dataset { } //#endregion // let agg; - let flatRowKey; - let flatColKey; + let flatRowKey: string; + let flatColKey: string; if (typeof rowKey === 'string') { flatRowKey = rowKey; } else { @@ -1200,7 +1293,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 && @@ -1227,7 +1320,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 && @@ -1241,6 +1334,41 @@ 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, + this.rows + ).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 && + !this.tree[flatRowKey]?.[flatColKey] && + this.getGrandTotalDimensionIndexes( + this.columns.filter((_, index) => this.columnsHasValue[index]), + this.totals?.column?.grandTotalDimensions, + this.columns + ).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; @@ -1392,7 +1520,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) { @@ -1441,18 +1569,22 @@ export class Dataset { let bChanged = b; 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); } } + 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); @@ -1490,14 +1622,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); } @@ -1653,63 +1787,80 @@ 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] !== this.groupedGrandTotalKey) { + const flatColTotalKeys = this.getGrandTotalKeys( + colKey, + that.columns.filter((_, index) => that.columnsHasValue[index]), + that.totals?.column?.grandTotalDimensions, + that.colGrandTotalLabel, + that.columns + ); + 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], + aggregationFun: aggRule?.aggregationFun, + formatFun: + aggRule?.formatFun ?? + ( + this.indicators?.find((indicator: string | IIndicator) => { + if (typeof indicator !== 'string') { + return indicator.indicatorKey === toComputeIndicatorKeys[i]; + } + return false; + }) 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) { + this.tree[flatRowKey][flatColTotalKey][i].push(that.tree[flatRowKey]?.[flatColKey]?.[i]); + } } } } @@ -1815,60 +1966,78 @@ 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] !== this.groupedGrandTotalKey + ) { + const flatRowTotalKeys = this.getGrandTotalKeys( + rowKey, + that.rows.filter((_, index) => that.rowsHasValue[index]), + that.totals?.row?.grandTotalDimensions, + that.rowGrandTotalLabel, + that.rows + ); + 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], + aggregationFun: aggRule?.aggregationFun, + formatFun: + aggRule?.formatFun ?? + ( + this.indicators?.find((indicator: string | IIndicator) => { + if (typeof indicator !== 'string') { + return indicator.indicatorKey === toComputeIndicatorKeys[i]; + } + return false; + }) 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) { + 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 +2097,111 @@ export class Dataset { * @param arr * @returns */ + private getGrandTotalDimensionIndexes( + dimensions: string[], + grandTotalDimensions?: string[], + configuredDimensions: string[] = dimensions + ) { + if (!grandTotalDimensions?.length) { + return []; + } + return grandTotalDimensions + .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); + } + + private getCustomGroupedGrandTotalKey( + record: any, + dimensions: string[], + grandTotalDimensions?: string[] + ): string[] | undefined { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions); + if ( + !dimensionIndexes.length || + dimensions.some((dimension, index) => dimensionIndexes.indexOf(index) === -1 && dimension in record) + ) { + return undefined; + } + 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( + keys: string[][], + dimensions: string[], + grandTotalDimensions: string[] | undefined, + isRow: boolean = true, + configuredDimensions: string[] = dimensions + ) { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions, configuredDimensions); + 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, + [this.groupedGrandTotalKey] + ) + ); + } + + private getGrandTotalKeys( + dimensionKey: string[], + dimensions: string[], + grandTotalDimensions: string[] | undefined, + grandTotalLabel: string, + configuredDimensions: string[] = dimensions + ) { + const dimensionIndexes = this.getGrandTotalDimensionIndexes(dimensions, grandTotalDimensions, configuredDimensions); + if (!dimensionIndexes.length) { + return [grandTotalLabel]; + } + const groupedKey = [ + this.groupedGrandTotalKey, + ...dimensionIndexes.map(dimensionIndex => dimensionKey[dimensionIndex]) + ]; + return [ + ...groupedKey.slice(1).map((_, index) => join(groupedKey.slice(0, index + 2), this.stringJoinChar)), + join([this.groupedGrandTotalKey, this.groupedGrandTotalSubtotalKey], this.stringJoinChar) + ]; + } + private ArrToTree1( arr: string[][], rows: string[], indicators: (string | IIndicator)[] | undefined, isGrandTotal: boolean, grandTotalLabel: string, - showGrandTotalsOnTop: boolean + showGrandTotalsOnTop: boolean, + grandTotalDimensions?: string[], + subTotalLabel?: string, + showSubTotalsOnTop?: boolean, + configuredRows: string[] = rows ) { /** * @@ -1991,9 +2258,72 @@ 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, configuredRows); + if (!grandTotalDimensionIndexes.length) { + addList([grandTotalLabel], isGrandTotal); + } else { + const grandTotalNode: any = { + value: grandTotalLabel, + dataValue: this.groupedGrandTotalKey, + dimensionKey: rows[0], + children: [], + role: 'grand-total' + }; + const grandTotalMap = new Map(); + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, true, configuredRows).forEach(list => { + let parent = grandTotalNode; + const path = [this.groupedGrandTotalKey]; + grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { + 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() : [] + }; + grandTotalMap.set(flatKey, item); + parent.children.push(item); + } + parent = item; + }); + }); + 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 { + result.push(grandTotalNode); + } + } } return result; } @@ -2012,7 +2342,10 @@ export class Dataset { grandTotalLabel: string, subTotalLabel: string, showGrandTotalsOnTop: boolean, - showSubTotalsOnTop: boolean + showSubTotalsOnTop: boolean, + grandTotalDimensions?: string[], + isRow?: boolean, + configuredRows: string[] = rows ) { /** * @@ -2120,25 +2453,75 @@ export class Dataset { } //最后将总计的节点加上 if (isGrandTotal && arr?.length) { - 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 - }; - } + const grandTotalDimensionIndexes = this.getGrandTotalDimensionIndexes(rows, grandTotalDimensions, configuredRows); + const createIndicatorChildren = () => + indicators?.map(indicator => { + if (typeof indicator === 'string') { return { - indicatorKey: indicator.indicatorKey, - value: indicator.title + indicatorKey: indicator, + value: indicator }; - }) ?? [], + } + return { + indicatorKey: indicator.indicatorKey, + value: indicator.title + }; + }) ?? []; + 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: Math.max(1, grandTotalDimensionIndexes[0] ?? subTotalFlags.length), + children: [], role: 'grand-total' }; + if (grandTotalDimensionIndexes.length) { + const grandTotalMap = new Map(); + this.getGroupedGrandTotalPaths(arr, rows, grandTotalDimensions, isRow, configuredRows).forEach(list => { + let parent = node; + const path = [this.groupedGrandTotalKey]; + grandTotalDimensionIndexes.forEach((dimensionIndex, index) => { + const value = list[index]; + path.push(value); + const flatKey = join(path, concatStr); + let item = grandTotalMap.get(flatKey); + if (!item) { + const nextDimensionIndex = grandTotalDimensionIndexes[index + 1] ?? rows.length; + item = { + value, + dataValue: 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, + dataValue: this.groupedGrandTotalSubtotalKey, + 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 { @@ -2209,7 +2592,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 && @@ -2236,7 +2619,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 && @@ -2328,15 +2711,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 +2743,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 +2759,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.filter((_, index) => this.rowsHasValue[index]), + this.totals.row.grandTotalDimensions, + this.rowGrandTotalLabel, + this.rows + ); + 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.filter((_, index) => this.columnsHasValue[index]), + this.totals.column.grandTotalDimensions, + this.colGrandTotalLabel, + this.columns + ); + 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/layout/pivot-header-layout.ts b/packages/vtable/src/layout/pivot-header-layout.ts index c05a9dacb6..4220b87f9b 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); @@ -3419,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))); @@ -3621,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 as any).define?.dataValue ?? hd.title) === rowDimension.dataValue + : hd?.title === rowDimension.value) ); }) .map((hd: HeaderData) => { @@ -3634,17 +3644,24 @@ 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 => { - const curHd = this._headerObjectMap[id]; - return rowHeaderPaths.find(rowDimensionPath => { - return rowDimensionPath.dimensionKey === curHd.field && 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)) + )) ); }); - 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 @@ -3659,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; @@ -4254,7 +4271,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) ]; @@ -4287,7 +4304,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) ]; @@ -4358,7 +4375,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); } }); } @@ -4374,7 +4391,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/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') { 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; diff --git a/packages/vtable/src/ts-types/dataset/aggregation.ts b/packages/vtable/src/ts-types/dataset/aggregation.ts index e6e4df96d9..f1aad2d0ae 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,24 @@ export class CustomAggregator extends Aggregator { this.fieldValue = undefined; } recalculate() { + if (this.recalculateFromChildren && this.children?.length && this.field) { + this.values = []; + this.records = []; + this.children.forEach(child => { + if (isValid(child.changedValue)) { + 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 { + const records = child.records; + this.values.push(...records.map(record => record[this.field as string])); + this.records.push(...records); + } + }); + } this.fieldValue = undefined; this._formatedValue = undefined; // do nothing 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; /** 汇总节点显示名称 默认'小计' */ 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 */