diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/pivot/pivot_model.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/pivot/pivot_model.js')
| -rw-r--r-- | addons/web/static/src/js/views/pivot/pivot_model.js | 1569 |
1 files changed, 1569 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js new file mode 100644 index 00000000..b4af34e0 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -0,0 +1,1569 @@ +odoo.define('web.PivotModel', function (require) { +"use strict"; + +/** + * Pivot Model + * + * The pivot model keeps an in-memory representation of the pivot table that is + * displayed on the screen. The exact layout of this representation is not so + * simple, because a pivot table is at its core a 2-dimensional object, but + * with a 'tree' component: some rows/cols can be expanded so we zoom into the + * structure. + * + * However, we need to be able to manipulate the data in a somewhat efficient + * way, and to transform it into a list of lines to be displayed by the renderer. + * + * Basicaly the pivot table presents aggregated values for various groups of records + * in one domain. If a comparison is asked for, two domains are considered. + * + * Let us consider a simple example and let us fix the vocabulary (let us suppose we are in June 2020): + * ___________________________________________________________________________________________________________________________________________ + * | | Total | + * | |_____________________________________________________________________________________________________________________| + * | | Sale Team 1 | Sale Team 2 | | + * | |_______________________________________|______________________________________|______________________________________| + * | | Sales total | Sales total | Sales total | + * | |_______________________________________|______________________________________|______________________________________| + * | | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation | + * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________| + * | Total | 85 | 110 | 29.4% | 40 | 30 | -25% | 125 | 140 | 12% | + * | Europe | 25 | 35 | 40% | 40 | 30 | -25% | 65 | 65 | 0% | + * | Brussels | 0 | 15 | 100% | 30 | 30 | 0% | 30 | 45 | 50% | + * | Paris | 25 | 20 | -20% | 10 | 0 | -100% | 35 | 20 | -42.8% | + * | North America | 60 | 75 | 25% | | | | 60 | 75 | 25% | + * | Washington | 60 | 75 | 25% | | | | 60 | 75 | 25% | + * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________| + * + * + * META DATA: + * + * In the above pivot table, the records have been grouped using the fields + * + * continent_id, city_id + * + * for rows and + * + * sale_team_id + * + * for columns. + * + * The measure is the field 'sales_total'. + * + * Two domains are considered: 'May 2020' and 'June 2020'. + * + * In the model, + * + * - rowGroupBys is the list [continent_id, city_id] + * - colGroupBys is the list [sale_team_id] + * - measures is the list [sales_total] + * - domains is the list [d1, d2] with d1 and d2 domain expressions + * for say sale_date in May 2020 and June 2020, for instance + * d1 = [['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31]] + * - origins is the list ['May 2020', 'June 2020'] + * + * DATA: + * + * Recall that a group is constituted by records (in a given domain) + * that have the same (raw) values for a list of fields. + * Thus the group itself is identified by this list and the domain. + * In comparison mode, the same group (forgetting the domain part or 'originIndex') + * can be eventually found in the two domains. + * This defines the way in which the groups are identified or not. + * + * In the above table, (forgetting the domain) the following groups are found: + * + * the 'row groups' + * - Total + * - Europe + * - America + * - Europe, Brussels + * - Europe, Paris + * - America, Washington + * + * the 'col groups' + * + * - Total + * - Sale Team 1 + * - Sale Team 2 + * + * and all non trivial combinations of row groups and col groups + * + * - Europe, Sale Team 1 + * - Europe, Brussels, Sale Team 2 + * - America, Washington, Sale Team 1 + * - ... + * + * The list of fields is created from the concatenation of two lists of fields, the first in + * + * [], [f1], [f1, f2], ... [f1, f2, ..., fn] for [f1, f2, ..., fn] the full list of groupbys + * (called rowGroupBys) used to create row groups + * + * In the example: [], [continent_id], [continent_id, city_id]. + * + * and the second in + * [], [g1], [g1, g2], ... [g1, g2, ..., gm] for [g1, g2, ..., gm] the full list of groupbys + * (called colGroupBys) used to create col groups. + * + * In the example: [], [sale_team_id]. + * + * Thus there are (n+1)*(m+1) lists of fields possible. + * + * In the example: 6 lists possible, namely [], + * [continent_id], [sale_team_id], + * [continent_id, sale_team_id], [continent_id, city_id], + * [continent_id, city_id, sale_team_id] + * + * A given list is thus of the form [f1,..., fi, g1,..., gj] or better [[f1,...,fi], [g1,...,gj]] + * + * For each list of fields possible and each domain considered, one read_group is done + * and gives results of the form (an exception for list []) + * + * g = { + * f1: v1, ..., fi: vi, + * g1: w1, ..., gj: wj, + * m1: x1, ..., mk: xk, + * __count: c, + * __domain: d + * } + * + * where v1,...,vi,w1,...,Wj are 'values' for the corresponding fields and + * m1,...,mk are the fields selected as measures. + * + * For example, g = { + * continent_id: [1, 'Europe'] + * sale_team_id: [1, 'Sale Team 1'] + * sales_count: 25, + * __count: 4 + * __domain: [ + * ['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31], + * ['continent_id', '=', 1], + * ['sale_team_id', '=', 1] + * ] + * } + * + * Thus the above group g is fully determined by [[v1,...,vi], [w1,...,wj]] and the base domain + * or the corresponding 'originIndex'. + * + * When j=0, g corresponds to a row group (or also row header) and is of the form [[v1,...,vi], []] or more simply [v1,...vi] + * (not forgetting the list [v1,...vi] comes from left). + * When i=0, g corresponds to a col group (or col header) and is of the form [[], [w1,...,wj]] or more simply [w1,...,wj]. + * + * A generic group g as above [[v1,...,vi], [w1,...,wj]] corresponds to the two headers [[v1,...,vi], []] + * and [[], [w1,...,wj]]. + * + * Here is a description of the data structure manipulated by the pivot model. + * + * Five objects contain all the data from the read_groups + * + * - rowGroupTree: contains information on row headers + * the nodes correspond to the groups of the form [[v1,...,vi], []] + * The root is [[], []]. + * A node [[v1,...,vl], []] has as direct children the nodes of the form [[v1,...,vl,v], []], + * this means that a direct child is obtained by grouping records using the single field fi+1 + * + * The structure at each level is of the form + * + * { + * root: { + * values: [v1,...,vl], + * labels: [la1,...,lal] + * }, + * directSubTrees: { + * v => { + * root: { + * values: [v1,...,vl,v] + * labels: [label1,...,labell,label] + * }, + * directSubTrees: {...} + * }, + * v' => {...}, + * ... + * } + * } + * + * (directSubTrees is a Map instance) + * + * In the example, the rowGroupTree is: + * + * { + * root: { + * values: [], + * labels: [] + * }, + * directSubTrees: { + * 1 => { + * root: { + * values: [1], + * labels: ['Europe'], + * }, + * directSubTrees: { + * 1 => { + * root: { + * values: [1, 1], + * labels: ['Europe', 'Brussels'], + * }, + * directSubTrees: new Map(), + * }, + * 2 => { + * root: { + * values: [1, 2], + * labels: ['Europe', 'Paris'], + * }, + * directSubTrees: new Map(), + * }, + * }, + * }, + * 2 => { + * root: { + * values: [2], + * labels: ['America'], + * }, + * directSubTrees: { + * 3 => { + * root: { + * values: [2, 3], + * labels: ['America', 'Washington'], + * } + * directSubTrees: new Map(), + * }, + * }, + * }, + * }, + * } + * + * - colGroupTree: contains information on col headers + * The same as above with right instead of left + * + * - measurements: contains information on measure values for all the groups + * + * the object keys are of the form JSON.stringify([[v1,...,vi], [w1,...,wj]]) + * and values are arrays of length equal to number of origins containing objects of the form + * {m1: x1,...,mk: xk} + * The structure looks like + * + * { + * JSON.stringify([[], []]): [{m1: x1,...,mk: xk}, {m1: x1',...,mk: xk'},...] + * .... + * JSON.stringify([[v1,...,vi], [w1,...,wj]]): [{m1: y1',...,mk: yk'}, {m1: y1',...,mk: yk'},...], + * .... + * JSON.stringify([[v1,...,vn], [w1,...,wm]]): [{m1: z1',...,mk: zk'}, {m1: z1',...,mk: zk'},...], + * } + * Thus the structure contains all information for all groups and all origins on measure values. + * + * + * this.measurments["[[], []]"][0]['foo'] gives the value of the measure 'foo' for the group 'Total' and the + * first domain (origin). + * + * In the example: + * { + * "[[], []]": [{'sales_total': 125}, {'sales_total': 140}] (total/total) + * ... + * "[[1, 2], [2]]": [{'sales_total': 10}, {'sales_total': 0}] (Europe/Paris/Sale Team 2) + * ... + * } + * + * - counts: contains information on the number of records in each groups + * The structure is similar to the above but the arrays contains numbers (counts) + * - groupDomains: + * The structure is similar to the above but the arrays contains domains + * + * With this light data structures, all manipulation done by the model are eased and redundancies are limited. + * Each time a rendering or an export of the data has to be done, the pivot table is generated by the _getTable function. + */ + +var AbstractModel = require('web.AbstractModel'); +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var dataComparisonUtils = require('web.dataComparisonUtils'); +const Domain = require('web.Domain'); +var mathUtils = require('web.mathUtils'); +var session = require('web.session'); + + +var _t = core._t; +var cartesian = mathUtils.cartesian; +var computeVariation = dataComparisonUtils.computeVariation; +var sections = mathUtils.sections; + +var PivotModel = AbstractModel.extend({ + /** + * @override + * @param {Object} params + */ + init: function () { + this._super.apply(this, arguments); + this.numbering = {}; + this.data = null; + this._loadDataDropPrevious = new concurrency.DropPrevious(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a groupBy to rowGroupBys or colGroupBys according to provided type. + * + * @param {string} groupBy + * @param {'row'|'col'} type + */ + addGroupBy: function (groupBy, type) { + if (type === 'row') { + this.data.expandedRowGroupBys.push(groupBy); + } else { + this.data.expandedColGroupBys.push(groupBy); + } + }, + /** + * Close the group with id given by groupId. A type must be specified + * in case groupId is [[], []] (the id of the group 'Total') because this + * group is present in both colGroupTree and rowGroupTree. + * + * @param {Array[]} groupId + * @param {'row'|'col'} type + */ + closeGroup: function (groupId, type) { + var groupBys; + var expandedGroupBys; + let keyPart; + var group; + var tree; + if (type === 'row') { + groupBys = this.data.rowGroupBys; + expandedGroupBys = this.data.expandedRowGroupBys; + tree = this.rowGroupTree; + group = this._findGroup(this.rowGroupTree, groupId[0]); + keyPart = 0; + } else { + groupBys = this.data.colGroupBys; + expandedGroupBys = this.data.expandedColGroupBys; + tree = this.colGroupTree; + group = this._findGroup(this.colGroupTree, groupId[1]); + keyPart = 1; + } + + const groupIdPart = groupId[keyPart]; + const range = groupIdPart.map((_, index) => index); + function keep(key) { + const idPart = JSON.parse(key)[keyPart]; + return range.some(index => groupIdPart[index] !== idPart[index]) || + idPart.length === groupIdPart.length; + } + function omitKeys(object) { + const newObject = {}; + for (const key in object) { + if (keep(key)) { + newObject[key] = object[key]; + } + } + return newObject; + } + this.measurements = omitKeys(this.measurements); + this.counts = omitKeys(this.counts); + this.groupDomains = omitKeys(this.groupDomains); + + group.directSubTrees.clear(); + delete group.sortedKeys; + var newGroupBysLength = this._getTreeHeight(tree) - 1; + if (newGroupBysLength <= groupBys.length) { + expandedGroupBys.splice(0); + groupBys.splice(newGroupBysLength); + } else { + expandedGroupBys.splice(newGroupBysLength - groupBys.length); + } + }, + /** + * Reload the view with the current rowGroupBys and colGroupBys + * This is the easiest way to expand all the groups that are not expanded + * + * @returns {Promise} + */ + expandAll: function () { + return this._loadData(); + }, + /** + * Expand a group by using groupBy to split it. + * + * @param {Object} group + * @param {string} groupBy + * @returns {Promise} + */ + expandGroup: async function (group, groupBy) { + var leftDivisors; + var rightDivisors; + + if (group.type === 'row') { + leftDivisors = [[groupBy]]; + rightDivisors = sections(this._getGroupBys().colGroupBys); + } else { + leftDivisors = sections(this._getGroupBys().rowGroupBys); + rightDivisors = [[groupBy]]; + } + var divisors = cartesian(leftDivisors, rightDivisors); + + delete group.type; + return this._subdivideGroup(group, divisors); + }, + /** + * Export model data in a form suitable for an easy encoding of the pivot + * table in excell. + * + * @returns {Object} + */ + exportData: function () { + var measureCount = this.data.measures.length; + var originCount = this.data.origins.length; + + var table = this._getTable(); + + // process headers + var headers = table.headers; + var colGroupHeaderRows; + var measureRow = []; + var originRow = []; + + function processHeader(header) { + var inTotalColumn = header.groupId[1].length === 0; + return { + title: header.title, + width: header.width, + height: header.height, + is_bold: !!header.measure && inTotalColumn + }; + } + + if (originCount > 1) { + colGroupHeaderRows = headers.slice(0, headers.length - 2); + measureRow = headers[headers.length - 2].map(processHeader); + originRow = headers[headers.length - 1].map(processHeader); + } else { + colGroupHeaderRows = headers.slice(0, headers.length - 1); + measureRow = headers[headers.length - 1].map(processHeader); + } + + // remove the empty headers on left side + colGroupHeaderRows[0].splice(0, 1); + + colGroupHeaderRows = colGroupHeaderRows.map(function (headerRow) { + return headerRow.map(processHeader); + }); + + // process rows + var tableRows = table.rows.map(function (row) { + return { + title: row.title, + indent: row.indent, + values: row.subGroupMeasurements.map(function (measurement) { + var value = measurement.value; + if (value === undefined) { + value = ""; + } else if (measurement.originIndexes.length > 1) { + // in that case the value is a variation and a + // number between 0 and 1 + value = value * 100; + } + return { + is_bold: measurement.isBold, + value: value, + }; + }), + }; + }); + + return { + col_group_headers: colGroupHeaderRows, + measure_headers: measureRow, + origin_headers: originRow, + rows: tableRows, + measure_count: measureCount, + origin_count: originCount, + }; + }, + /** + * Swap the pivot columns and the rows. It is a synchronous operation. + */ + flip: function () { + // swap the data: the main column and the main row + var temp = this.rowGroupTree; + this.rowGroupTree = this.colGroupTree; + this.colGroupTree = temp; + + // we need to update the record metadata: (expanded) row and col groupBys + temp = this.data.rowGroupBys; + this.data.groupedBy = this.data.colGroupBys; + this.data.rowGroupBys = this.data.colGroupBys; + this.data.colGroupBys = temp; + temp = this.data.expandedColGroupBys; + this.data.expandedColGroupBys = this.data.expandedRowGroupBys; + this.data.expandedRowGroupBys = temp; + + function twistKey(key) { + return JSON.stringify(JSON.parse(key).reverse()); + } + + function twist(object) { + var newObject = {}; + Object.keys(object).forEach(function (key) { + var value = object[key]; + newObject[twistKey(key)] = value; + }); + return newObject; + } + + this.measurements = twist(this.measurements); + this.counts = twist(this.counts); + this.groupDomains = twist(this.groupDomains); + }, + /** + * @override + * + * @param {Object} [options] + * @param {boolean} [options.raw=false] + * @returns {Object} + */ + __get: function (options) { + options = options || {}; + var raw = options.raw || false; + var groupBys = this._getGroupBys(); + var state = { + colGroupBys: groupBys.colGroupBys, + context: this.data.context, + domain: this.data.domain, + fields: this.fields, + hasData: this._hasData(), + isSample: this.isSampleModel, + measures: this.data.measures, + origins: this.data.origins, + rowGroupBys: groupBys.rowGroupBys, + selectionGroupBys: this._getSelectionGroupBy(groupBys), + modelName: this.modelName + }; + if (!raw && state.hasData) { + state.table = this._getTable(); + state.tree = this.rowGroupTree; + } + return state; + }, + /** + * Returns the total number of columns of the pivot table. + * + * @returns {integer} + */ + getTableWidth: function () { + var leafCounts = this._getLeafCounts(this.colGroupTree); + return leafCounts[JSON.stringify(this.colGroupTree.root.values)] + 2; + }, + /** + * @override + * + * @param {Object} params + * @param {boolean} [params.compare=false] + * @param {Object} params.context + * @param {Object} params.fields + * @param {string[]} [params.groupedBy] + * @param {string[]} params.colGroupBys + * @param {Array[]} params.domain + * @param {string[]} params.measures + * @param {string[]} params.rowGroupBys + * @param {string} [params.default_order] + * @param {string} params.modelName + * @param {Object[]} params.groupableFields + * @param {Object} params.timeRanges + * @returns {Promise} + */ + __load: function (params) { + this.initialDomain = params.domain; + this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys; + this.defaultGroupedBy = params.groupedBy; + + this.fields = params.fields; + this.modelName = params.modelName; + this.groupableFields = params.groupableFields; + const measures = this._processMeasures(params.context.pivot_measures) || + params.measures.map(m => m); + this.data = { + expandedRowGroupBys: [], + expandedColGroupBys: [], + domain: this.initialDomain, + context: _.extend({}, session.user_context, params.context), + groupedBy: params.context.pivot_row_groupby || params.groupedBy, + colGroupBys: params.context.pivot_column_groupby || params.colGroupBys, + measures, + timeRanges: params.timeRanges, + }; + this._computeDerivedParams(); + + this.data.groupedBy = this.data.groupedBy.slice(); + this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys.slice(); + + var defaultOrder = params.default_order && params.default_order.split(' '); + if (defaultOrder) { + this.data.sortedColumn = { + groupId: [[], []], + measure: defaultOrder[0], + order: defaultOrder[1] ? defaultOrder [1] : 'asc', + }; + } + return this._loadData(); + }, + /** + * @override + * + * @param {any} handle this parameter is ignored + * @param {Object} params + * @param {boolean} [params.compare=false] + * @param {Object} params.context + * @param {string[]} [params.groupedBy] + * @param {Array[]} params.domain + * @param {string[]} params.groupBy + * @param {string[]} params.measures + * @param {Object} [params.timeRanges] + * @returns {Promise} + */ + __reload: function (handle, params) { + var self = this; + var oldColGroupBys = this.data.colGroupBys; + var oldRowGroupBys = this.data.rowGroupBys; + if ('context' in params) { + this.data.context = params.context; + this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys; + this.data.groupedBy = params.context.pivot_row_groupby || this.data.groupedBy; + this.data.measures = this._processMeasures(params.context.pivot_measures) || this.data.measures; + this.defaultGroupedBy = this.data.groupedBy.length ? this.data.groupedBy : this.defaultGroupedBy; + } + if ('domain' in params) { + this.data.domain = params.domain; + this.initialDomain = params.domain; + } else { + this.data.domain = this.initialDomain; + } + if ('groupBy' in params) { + this.data.groupedBy = params.groupBy.length ? params.groupBy : this.defaultGroupedBy; + } + if ('timeRanges' in params) { + this.data.timeRanges = params.timeRanges; + } + this._computeDerivedParams(); + + this.data.groupedBy = this.data.groupedBy.slice(); + this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys.slice(); + + if (!_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) { + this.data.expandedRowGroupBys = []; + } + if (!_.isEqual(oldColGroupBys, self.data.colGroupBys)) { + this.data.expandedColGroupBys = []; + } + + if ('measure' in params) { + return this._toggleMeasure(params.measure); + } + + if (!this._hasData()) { + return this._loadData(); + } + + var oldRowGroupTree = this.rowGroupTree; + var oldColGroupTree = this.colGroupTree; + return this._loadData().then(function () { + if (_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) { + self._pruneTree(self.rowGroupTree, oldRowGroupTree); + } + if (_.isEqual(oldColGroupBys, self.data.colGroupBys)) { + self._pruneTree(self.colGroupTree, oldColGroupTree); + } + }); + }, + /** + * Sort the rows, depending on the values of a given column. This is an + * in-memory sort. + * + * @param {Object} sortedColumn + * @param {number[]} sortedColumn.groupId + */ + sortRows: function (sortedColumn) { + var self = this; + var colGroupValues = sortedColumn.groupId[1]; + sortedColumn.originIndexes = sortedColumn.originIndexes || [0]; + this.data.sortedColumn = sortedColumn; + + var sortFunction = function (tree) { + return function (subTreeKey) { + var subTree = tree.directSubTrees.get(subTreeKey); + var groupIntersectionId = [subTree.root.values, colGroupValues]; + var value = self._getCellValue( + groupIntersectionId, + sortedColumn.measure, + sortedColumn.originIndexes + ) || 0; + return sortedColumn.order === 'asc' ? value : -value; + }; + }; + + this._sortTree(sortFunction, this.rowGroupTree); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add labels/values in the provided groupTree. A new leaf is created in + * the groupTree with a root object corresponding to the group with given + * labels/values. + * + * @private + * @param {Object} groupTree, either this.rowGroupTree or this.colGroupTree + * @param {string[]} labels + * @param {Array} values + */ + _addGroup: function (groupTree, labels, values) { + var tree = groupTree; + // we assume here that the group with value value.slice(value.length - 2) has already been added. + values.slice(0, values.length - 1).forEach(function (value) { + tree = tree.directSubTrees.get(value); + }); + tree.directSubTrees.set(values[values.length - 1], { + root: { + labels: labels, + values: values, + }, + directSubTrees: new Map(), + }); + }, + /** + * Compute what should be used as rowGroupBys by the pivot view + * + * @private + * @returns {string[]} + */ + _computeRowGroupBys: function () { + return !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys; + }, + /** + * Find a group with given values in the provided groupTree, either + * this.rowGrouptree or this.colGroupTree. + * + * @private + * @param {Object} groupTree + * @param {Array} values + * @returns {Object} + */ + _findGroup: function (groupTree, values) { + var tree = groupTree; + values.slice(0, values.length).forEach(function (value) { + tree = tree.directSubTrees.get(value); + }); + return tree; + }, + /** + * In case originIndex is an array of length 1, thus a single origin + * index, returns the given measure for a group determined by the id + * groupId and the origin index. + * If originIndexes is an array of length 2, we compute the variation + * ot the measure values for the groups determined by groupId and the + * different origin indexes. + * + * @private + * @param {Array[]} groupId + * @param {string} measure + * @param {number[]} originIndexes + * @returns {number} + */ + _getCellValue: function (groupId, measure, originIndexes) { + var self = this; + var key = JSON.stringify(groupId); + if (!self.measurements[key]) { + return; + } + var values = originIndexes.map(function (originIndex) { + return self.measurements[key][originIndex][measure]; + }); + if (originIndexes.length > 1) { + return computeVariation(values[1], values[0]); + } else { + return values[0]; + } + }, + /** + * Returns the rowGroupBys and colGroupBys arrays that + * are actually used by the pivot view internally + * (for read_group or other purpose) + * + * @private + * @returns {Object} with keys colGroupBys and rowGroupBys + */ + _getGroupBys: function () { + return { + colGroupBys: this.data.colGroupBys.concat(this.data.expandedColGroupBys), + rowGroupBys: this.data.rowGroupBys.concat(this.data.expandedRowGroupBys), + }; + }, + /** + * Returns a domain representation of a group + * + * @private + * @param {Object} group + * @param {Array} group.colValues + * @param {Array} group.rowValues + * @param {number} group.originIndex + * @returns {Array[]} + */ + _getGroupDomain: function (group) { + var key = JSON.stringify([group.rowValues, group.colValues]); + return this.groupDomains[key][group.originIndex]; + }, + /** + * Returns the group sanitized labels. + * + * @private + * @param {Object} group + * @param {string[]} groupBys + * @returns {string[]} + */ + _getGroupLabels: function (group, groupBys) { + var self = this; + return groupBys.map(function (groupBy) { + return self._sanitizeLabel(group[groupBy], groupBy); + }); + }, + /** + * Returns a promise that returns the annotated read_group results + * corresponding to a partition of the given group obtained using the given + * rowGroupBy and colGroupBy. + * + * @private + * @param {Object} group + * @param {string[]} rowGroupBy + * @param {string[]} colGroupBy + * @returns {Promise} + */ + _getGroupSubdivision: function (group, rowGroupBy, colGroupBy) { + var groupDomain = this._getGroupDomain(group); + var measureSpecs = this._getMeasureSpecs(); + var groupBy = rowGroupBy.concat(colGroupBy); + return this._rpc({ + model: this.modelName, + method: 'read_group', + context: this.data.context, + domain: groupDomain, + fields: measureSpecs, + groupBy: groupBy, + lazy: false, + }).then(function (subGroups) { + return { + group: group, + subGroups: subGroups, + rowGroupBy: rowGroupBy, + colGroupBy: colGroupBy + }; + }); + }, + /** + * Returns the group sanitized values. + * + * @private + * @param {Object} group + * @param {string[]} groupBys + * @returns {Array} + */ + _getGroupValues: function (group, groupBys) { + var self = this; + return groupBys.map(function (groupBy) { + return self._sanitizeValue(group[groupBy]); + }); + }, + /** + * Returns the leaf counts of each group inside the given tree. + * + * @private + * @param {Object} tree + * @returns {Object} keys are group ids + */ + _getLeafCounts: function (tree) { + var self = this; + var leafCounts = {}; + var leafCount; + if (!tree.directSubTrees.size) { + leafCount = 1; + } else { + leafCount = [...tree.directSubTrees.values()].reduce( + function (acc, subTree) { + var subLeafCounts = self._getLeafCounts(subTree); + _.extend(leafCounts, subLeafCounts); + return acc + leafCounts[JSON.stringify(subTree.root.values)]; + }, + 0 + ); + } + + leafCounts[JSON.stringify(tree.root.values)] = leafCount; + return leafCounts; + }, + /** + * Returns the group sanitized measure values for the measures in + * this.data.measures (that migth contain '__count', not really a fieldName). + * + * @private + * @param {Object} group + * @returns {Array} + */ + _getMeasurements: function (group) { + var self = this; + return this.data.measures.reduce( + function (measurements, fieldName) { + var measurement = group[fieldName]; + if (measurement instanceof Array) { + // case field is many2one and used as measure and groupBy simultaneously + measurement = 1; + } + if (self.fields[fieldName].type === 'boolean' && measurement instanceof Boolean) { + measurement = measurement ? 1 : 0; + } + if (self.data.origins.length > 1 && !measurement) { + measurement = 0; + } + measurements[fieldName] = measurement; + return measurements; + }, + {} + ); + }, + /** + * Returns a description of the measures row of the pivot table + * + * @private + * @param {Object[]} columns for which measure cells must be generated + * @returns {Object[]} + */ + _getMeasuresRow: function (columns) { + var self = this; + var sortedColumn = this.data.sortedColumn || {}; + var measureRow = []; + + columns.forEach(function (column) { + self.data.measures.forEach(function (measure) { + var measureCell = { + groupId: column.groupId, + height: 1, + measure: measure, + title: self.fields[measure].string, + width: 2 * self.data.origins.length - 1, + }; + if (sortedColumn.measure === measure && + _.isEqual(sortedColumn.groupId, column.groupId)) { + measureCell.order = sortedColumn.order; + } + measureRow.push(measureCell); + }); + }); + + return measureRow; + }, + /** + * Returns the list of measure specs associated with data.measures, i.e. + * a measure 'fieldName' becomes 'fieldName:groupOperator' where + * groupOperator is the value specified on the field 'fieldName' for + * the key group_operator. + * + * @private + * @return {string[]} + */ + _getMeasureSpecs: function () { + var self = this; + return this.data.measures.reduce( + function (acc, measure) { + if (measure === '__count') { + acc.push(measure); + return acc; + } + var type = self.fields[measure].type; + var groupOperator = self.fields[measure].group_operator; + if (type === 'many2one') { + groupOperator = 'count_distinct'; + } + if (groupOperator === undefined) { + throw new Error("No aggregate function has been provided for the measure '" + measure + "'"); + } + acc.push(measure + ':' + groupOperator); + return acc; + }, + [] + ); + }, + /** + * Make sure that the labels of different many2one values are distinguished + * by numbering them if necessary. + * + * @private + * @param {Array} label + * @param {string} fieldName + * @returns {string} + */ + _getNumberedLabel: function (label, fieldName) { + var id = label[0]; + var name = label[1]; + this.numbering[fieldName] = this.numbering[fieldName] || {}; + this.numbering[fieldName][name] = this.numbering[fieldName][name] || {}; + var numbers = this.numbering[fieldName][name]; + numbers[id] = numbers[id] || _.size(numbers) + 1; + return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : ""); + }, + /** + * Returns a description of the origins row of the pivot table + * + * @private + * @param {Object[]} columns for which origin cells must be generated + * @returns {Object[]} + */ + _getOriginsRow: function (columns) { + var self = this; + var sortedColumn = this.data.sortedColumn || {}; + var originRow = []; + + columns.forEach(function (column) { + var groupId = column.groupId; + var measure = column.measure; + var isSorted = sortedColumn.measure === measure && + _.isEqual(sortedColumn.groupId, groupId); + var isSortedByOrigin = isSorted && !sortedColumn.originIndexes[1]; + var isSortedByVariation = isSorted && sortedColumn.originIndexes[1]; + + self.data.origins.forEach(function (origin, originIndex) { + var originCell = { + groupId: groupId, + height: 1, + measure: measure, + originIndexes: [originIndex], + title: origin, + width: 1, + }; + if (isSortedByOrigin && sortedColumn.originIndexes[0] === originIndex) { + originCell.order = sortedColumn.order; + } + originRow.push(originCell); + + if (originIndex > 0) { + var variationCell = { + groupId: groupId, + height: 1, + measure: measure, + originIndexes: [originIndex - 1, originIndex], + title: _t('Variation'), + width: 1, + }; + if (isSortedByVariation && sortedColumn.originIndexes[1] === originIndex) { + variationCell.order = sortedColumn.order; + } + originRow.push(variationCell); + } + + }); + }); + + return originRow; + }, + + /** + * Get the selection needed to display the group by dropdown + * @returns {Object[]} + * @private + */ + _getSelectionGroupBy: function (groupBys) { + let groupedFieldNames = groupBys.rowGroupBys + .concat(groupBys.colGroupBys) + .map(function (g) { + return g.split(':')[0]; + }); + + var fields = Object.keys(this.groupableFields) + .map((fieldName, index) => { + return { + name: fieldName, + field: this.groupableFields[fieldName], + active: groupedFieldNames.includes(fieldName) + } + }) + .sort((left, right) => left.field.string < right.field.string ? -1 : 1); + return fields; + }, + + /** + * Returns a description of the pivot table. + * + * @private + * @returns {Object} + */ + _getTable: function () { + var headers = this._getTableHeaders(); + return { + headers: headers, + rows: this._getTableRows(this.rowGroupTree, headers[headers.length - 1]), + }; + }, + /** + * Returns the list of header rows of the pivot table: the col group rows + * (depending on the col groupbys), the measures row and optionnaly the + * origins row (if there are more than one origins). + * + * @private + * @returns {Object[]} + */ + _getTableHeaders: function () { + var colGroupBys = this._getGroupBys().colGroupBys; + var height = colGroupBys.length + 1; + var measureCount = this.data.measures.length; + var originCount = this.data.origins.length; + var leafCounts = this._getLeafCounts(this.colGroupTree); + var headers = []; + var measureColumns = []; // used to generate the measure cells + + // 1) generate col group rows (total row + one row for each col groupby) + var colGroupRows = (new Array(height)).fill(0).map(function () { + return []; + }); + // blank top left cell + colGroupRows[0].push({ + height: height + 1 + (originCount > 1 ? 1 : 0), // + measures rows [+ origins row] + title: "", + width: 1, + }); + + // col groupby cells with group values + /** + * Recursive function that generates the header cells corresponding to + * the groups of a given tree. + * + * @param {Object} tree + */ + function generateTreeHeaders(tree, fields) { + var group = tree.root; + var rowIndex = group.values.length; + var row = colGroupRows[rowIndex]; + var groupId = [[], group.values]; + var isLeaf = !tree.directSubTrees.size; + var leafCount = leafCounts[JSON.stringify(tree.root.values)]; + var cell = { + groupId: groupId, + height: isLeaf ? (colGroupBys.length + 1 - rowIndex) : 1, + isLeaf: isLeaf, + label: rowIndex === 0 ? undefined : fields[colGroupBys[rowIndex - 1].split(':')[0]].string, + title: group.labels[group.labels.length - 1] || _t('Total'), + width: leafCount * measureCount * (2 * originCount - 1), + }; + row.push(cell); + if (isLeaf) { + measureColumns.push(cell); + } + + [...tree.directSubTrees.values()].forEach(function (subTree) { + generateTreeHeaders(subTree, fields); + }); + } + + generateTreeHeaders(this.colGroupTree, this.fields); + // blank top right cell for 'Total' group (if there is more that one leaf) + if (leafCounts[JSON.stringify(this.colGroupTree.root.values)] > 1) { + var groupId = [[], []]; + var totalTopRightCell = { + groupId: groupId, + height: height, + title: "", + width: measureCount * (2 * originCount - 1), + }; + colGroupRows[0].push(totalTopRightCell); + measureColumns.push(totalTopRightCell); + } + headers = headers.concat(colGroupRows); + + // 2) generate measures row + var measuresRow = this._getMeasuresRow(measureColumns); + headers.push(measuresRow); + + // 3) generate origins row if more than one origin + if (originCount > 1) { + headers.push(this._getOriginsRow(measuresRow)); + } + + return headers; + }, + /** + * Returns the list of body rows of the pivot table for a given tree. + * + * @private + * @param {Object} tree + * @param {Object[]} columns + * @returns {Object[]} + */ + _getTableRows: function (tree, columns) { + var self = this; + + var rows = []; + var group = tree.root; + var rowGroupId = [group.values, []]; + var title = group.labels[group.labels.length - 1] || _t('Total'); + var indent = group.labels.length; + var isLeaf = !tree.directSubTrees.size; + var rowGroupBys = this._getGroupBys().rowGroupBys; + + var subGroupMeasurements = columns.map(function (column) { + var colGroupId = column.groupId; + var groupIntersectionId = [rowGroupId[0], colGroupId[1]]; + var measure = column.measure; + var originIndexes = column.originIndexes || [0]; + + var value = self._getCellValue(groupIntersectionId, measure, originIndexes); + + var measurement = { + groupId: groupIntersectionId, + originIndexes: originIndexes, + measure: measure, + value: value, + isBold: !groupIntersectionId[0].length || !groupIntersectionId[1].length, + }; + return measurement; + }); + + rows.push({ + title: title, + label: indent === 0 ? undefined : this.fields[rowGroupBys[indent - 1].split(':')[0]].string, + groupId: rowGroupId, + indent: indent, + isLeaf: isLeaf, + subGroupMeasurements: subGroupMeasurements + }); + + var subTreeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()]; + subTreeKeys.forEach(function (subTreeKey) { + var subTree = tree.directSubTrees.get(subTreeKey); + rows = rows.concat(self._getTableRows(subTree, columns)); + }); + + return rows; + }, + /** + * returns the height of a given groupTree + * + * @private + * @param {Object} tree, a groupTree + * @returns {number} + */ + _getTreeHeight: function (tree) { + var subTreeHeights = [...tree.directSubTrees.values()].map(this._getTreeHeight.bind(this)); + return Math.max(0, Math.max.apply(null, subTreeHeights)) + 1; + }, + /** + * @private + * @returns {boolean} + */ + _hasData: function () { + return (this.counts[JSON.stringify([[], []])] || []).some(function (count) { + return count > 0; + }); + }, + /** + * @override + */ + _isEmpty() { + return !this._hasData(); + }, + /** + * Initilize/Reinitialize this.rowGroupTree, colGroupTree, measurements, + * counts and subdivide the group 'Total' as many times it is necessary. + * A first subdivision with no groupBy (divisors.slice(0, 1)) is made in + * order to see if there is data in the intersection of the group 'Total' + * and the various origins. In case there is none, nonsupplementary rpc + * will be done (see the code of subdivideGroup). + * Once the promise resolves, this.rowGroupTree, colGroupTree, + * measurements, counts are correctly set. + * + * @private + * @return {Promise} + */ + _loadData: function () { + var self = this; + + this.rowGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() }; + this.colGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() }; + this.measurements = {}; + this.counts = {}; + + var key = JSON.stringify([[], []]); + this.groupDomains = {}; + this.groupDomains[key] = this.data.domains.slice(0); + + + var group = { rowValues: [], colValues: [] }; + var groupBys = this._getGroupBys(); + var leftDivisors = sections(groupBys.rowGroupBys); + var rightDivisors = sections(groupBys.colGroupBys); + var divisors = cartesian(leftDivisors, rightDivisors); + + return this._subdivideGroup(group, divisors.slice(0, 1)).then(function () { + return self._subdivideGroup(group, divisors.slice(1)); + }); + }, + /** + * Extract the information in the read_group results (groupSubdivisions) + * and develop this.rowGroupTree, colGroupTree, measurements, counts, and + * groupDomains. + * If a column needs to be sorted, the rowGroupTree corresponding to the + * group is sorted. + * + * @private + * @param {Object} group + * @param {Object[]} groupSubdivisions + */ + _prepareData: function (group, groupSubdivisions) { + var self = this; + + var groupRowValues = group.rowValues; + var groupRowLabels = []; + var rowSubTree = this.rowGroupTree; + var root; + if (groupRowValues.length) { + // we should have labels information on hand! regretful! + rowSubTree = this._findGroup(this.rowGroupTree, groupRowValues); + root = rowSubTree.root; + groupRowLabels = root.labels; + } + + var groupColValues = group.colValues; + var groupColLabels = []; + if (groupColValues.length) { + root = this._findGroup(this.colGroupTree, groupColValues).root; + groupColLabels = root.labels; + } + + groupSubdivisions.forEach(function (groupSubdivision) { + groupSubdivision.subGroups.forEach(function (subGroup) { + + var rowValues = groupRowValues.concat(self._getGroupValues(subGroup, groupSubdivision.rowGroupBy)); + var rowLabels = groupRowLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.rowGroupBy)); + + var colValues = groupColValues.concat(self._getGroupValues(subGroup, groupSubdivision.colGroupBy)); + var colLabels = groupColLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.colGroupBy)); + + if (!colValues.length && rowValues.length) { + self._addGroup(self.rowGroupTree, rowLabels, rowValues); + } + if (colValues.length && !rowValues.length) { + self._addGroup(self.colGroupTree, colLabels, colValues); + } + + var key = JSON.stringify([rowValues, colValues]); + var originIndex = groupSubdivision.group.originIndex; + + if (!(key in self.measurements)) { + self.measurements[key] = self.data.origins.map(function () { + return self._getMeasurements({}); + }); + } + self.measurements[key][originIndex] = self._getMeasurements(subGroup); + + if (!(key in self.counts)) { + self.counts[key] = self.data.origins.map(function () { + return 0; + }); + } + self.counts[key][originIndex] = subGroup.__count; + + if (!(key in self.groupDomains)) { + self.groupDomains[key] = self.data.origins.map(function () { + return Domain.FALSE_DOMAIN; + }); + } + // if __domain is not defined this means that we are in the + // case where + // groupSubdivision.rowGroupBy = groupSubdivision.rowGroupBy = [] + if (subGroup.__domain) { + self.groupDomains[key][originIndex] = subGroup.__domain; + } + }); + }); + + if (this.data.sortedColumn) { + this.sortRows(this.data.sortedColumn, rowSubTree); + } + }, + /** + * In the preview implementation of the pivot view (a.k.a. version 2), + * the virtual field used to display the number of records was named + * __count__, whereas __count is actually the one used in xml. So + * basically, activating a filter specifying __count as measures crashed. + * Unfortunately, as __count__ was used in the JS, all filters saved as + * favorite at that time were saved with __count__, and not __count. + * So in order the make them still work with the new implementation, we + * handle both __count__ and __count. + * + * This function replaces in the given array of measures occurences of + * '__count__' by '__count'. + * + * @private + * @param {Array[string] || undefined} measures + * @returns {Array[string] || undefined} + */ + _processMeasures: function (measures) { + if (measures) { + return _.map(measures, function (measure) { + return measure === '__count__' ? '__count' : measure; + }); + } + }, + /** + * Determine this.data.domains and this.data.origins from + * this.data.domain and this.data.timeRanges; + * + * @private + */ + _computeDerivedParams: function () { + const { range, rangeDescription, comparisonRange, comparisonRangeDescription } = this.data.timeRanges; + if (range) { + this.data.domains = [this.data.domain.concat(comparisonRange), this.data.domain.concat(range)]; + this.data.origins = [comparisonRangeDescription, rangeDescription]; + } else { + this.data.domains = [this.data.domain]; + this.data.origins = [""]; + } + }, + /** + * Make any group in tree a leaf if it was a leaf in oldTree. + * + * @private + * @param {Object} tree + * @param {Object} oldTree + */ + _pruneTree: function (tree, oldTree) { + if (!oldTree.directSubTrees.size) { + tree.directSubTrees.clear(); + delete tree.sortedKeys; + return; + } + var self = this; + [...tree.directSubTrees.keys()].forEach(function (subTreeKey) { + var subTree = tree.directSubTrees.get(subTreeKey); + if (!oldTree.directSubTrees.has(subTreeKey)) { + subTree.directSubTrees.clear(); + delete subTreeKey.sortedKeys; + } else { + var oldSubTree = oldTree.directSubTrees.get(subTreeKey); + self._pruneTree(subTree, oldSubTree); + } + }); + }, + /** + * Toggle the active state for a given measure, then reload the data + * if this turns out to be necessary. + * + * @param {string} fieldName + * @returns {Promise} + */ + _toggleMeasure: function (fieldName) { + var index = this.data.measures.indexOf(fieldName); + if (index !== -1) { + this.data.measures.splice(index, 1); + // in this case, we already have all data in memory, no need to + // actually reload a lesser amount of information + return Promise.resolve(); + } else { + this.data.measures.push(fieldName); + } + return this._loadData(); + }, + /** + * Extract from a groupBy value a label. + * + * @private + * @param {any} value + * @param {string} groupBy + * @returns {string} + */ + _sanitizeLabel: function (value, groupBy) { + var fieldName = groupBy.split(':')[0]; + if (value === false) { + return _t("Undefined"); + } + if (value instanceof Array) { + return this._getNumberedLabel(value, fieldName); + } + if (fieldName && this.fields[fieldName] && (this.fields[fieldName].type === 'selection')) { + var selected = _.where(this.fields[fieldName].selection, { 0: value })[0]; + return selected ? selected[1] : value; + } + return value; + }, + /** + * Extract from a groupBy value the raw value of that groupBy (discarding + * a label if any) + * + * @private + * @param {any} value + * @returns {any} + */ + _sanitizeValue: function (value) { + if (value instanceof Array) { + return value[0]; + } + return value; + }, + /** + * Get all partitions of a given group using the provided list of divisors + * and enrich the objects of this.rowGroupTree, colGroupTree, + * measurements, counts. + * + * @private + * @param {Object} group + * @param {Array[]} divisors + * @returns + */ + _subdivideGroup: function (group, divisors) { + var self = this; + + var key = JSON.stringify([group.rowValues, group.colValues]); + + var proms = this.data.origins.reduce( + function (acc, origin, originIndex) { + // if no information on group content is available, we fetch data. + // if group is known to be empty for the given origin, + // we don't need to fetch data fot that origin. + if (!self.counts[key] || self.counts[key][originIndex] > 0) { + var subGroup = { + rowValues: group.rowValues, + colValues: group.colValues, + originIndex: originIndex + }; + divisors.forEach(function (divisor) { + acc.push(self._getGroupSubdivision(subGroup, divisor[0], divisor[1])); + }); + } + return acc; + }, + [] + ); + return this._loadDataDropPrevious.add(Promise.all(proms)).then(function (groupSubdivisions) { + if (groupSubdivisions.length) { + self._prepareData(group, groupSubdivisions); + } + }); + }, + /** + * Sort recursively the subTrees of tree using sortFunction. + * In the end each node of the tree has its direct children sorted + * according to the criterion reprensented by sortFunction. + * + * @private + * @param {Function} sortFunction + * @param {Object} tree + */ + _sortTree: function (sortFunction, tree) { + var self = this; + tree.sortedKeys = _.sortBy([...tree.directSubTrees.keys()], sortFunction(tree)); + [...tree.directSubTrees.values()].forEach(function (subTree) { + self._sortTree(sortFunction, subTree); + }); + }, +}); + +return PivotModel; + +}); |
