/** @odoo-module */ import { _t } from "@web/core/l10n/translation"; import { Domain } from "@web/core/domain"; import { sprintf } from "@web/core/utils/strings"; import { PivotModel } from "@web/views/pivot/pivot_model"; import { computeReportMeasures } from "@web/views/utils"; import { session } from "@web/session"; import { FORMATS } from "../helpers/constants"; import spreadsheet from "../o_spreadsheet/o_spreadsheet_extended"; import { formatDate } from "./pivot_helpers"; import { PERIODS } from "@spreadsheet/pivot/pivot_helpers"; import { SpreadsheetPivotTable } from "@spreadsheet/pivot/pivot_table"; const { toString, toNumber, toBoolean } = spreadsheet.helpers; /** * @typedef {import("@spreadsheet/data_sources/metadata_repository").Field} Field * @typedef {import("@spreadsheet/pivot/pivot_table").Row} Row * @typedef {import("@spreadsheet/pivot/pivot_table").Column} Column * * @typedef {Object} PivotMetaData * @property {Array} colGroupBys * @property {Array} rowGroupBys * @property {Array} activeMeasures * @property {string} resModel * @property {Record} fields * @property {string|undefined} modelLabel * * @typedef {Object} PivotSearchParams * @property {Array} groupBy * @property {Array} orderBy * @property {Object} domain * @property {Object} context */ /** * Parses the positional char (#), the field and operator string of pivot group. * e.g. "create_date:month" * @param {Record} allFields * @param {string} groupFieldString * @returns {{field: Field, aggregateOperator: string, isPositional: boolean}} */ function parseGroupField(allFields, groupFieldString) { let [fieldName, aggregateOperator] = groupFieldString.split(":"); const isPositional = fieldName.startsWith("#"); fieldName = isPositional ? fieldName.substring(1) : fieldName; const field = allFields[fieldName]; if (field === undefined) { throw new Error(sprintf(_t("Field %s does not exist"), fieldName)); } if (["date", "datetime"].includes(field.type)) { aggregateOperator = aggregateOperator || "month"; } return { isPositional, field, aggregateOperator, }; } const UNSUPPORTED_FIELD_TYPES = ["one2many", "binary", "html"]; export const NO_RECORD_AT_THIS_POSITION = Symbol("NO_RECORD_AT_THIS_POSITION"); function isNotSupported(fieldType) { return UNSUPPORTED_FIELD_TYPES.includes(fieldType); } function throwUnsupportedFieldError(field) { throw new Error( sprintf(_t("Field %s is not supported because of its type (%s)"), field.string, field.type) ); } /** * Parses the value defining a pivot group in a PIVOT formula * e.g. given the following formula PIVOT("1", "stage_id", "42", "status", "won"), * the two group values are "42" and "won". * @param {object} field * @param {number | boolean | string} groupValue * @returns {number | boolean | string} */ export function parsePivotFormulaFieldValue(field, groupValue) { const groupValueString = typeof groupValue === "boolean" ? toString(groupValue).toLocaleLowerCase() : toString(groupValue); if (isNotSupported(field.type)) { throwUnsupportedFieldError(field); } // represents a field which is not set (=False server side) if (groupValueString === "false") { return false; } switch (field.type) { case "datetime": case "date": return toString(groupValueString); case "selection": case "char": case "text": return toString(groupValueString); case "boolean": return toBoolean(groupValueString); case "float": case "integer": case "monetary": case "many2one": case "many2many": return toNumber(groupValueString); default: throwUnsupportedFieldError(field); } } /** * This class is an extension of PivotModel with some additional information * that we need in spreadsheet (name_get, isUsedInSheet, ...) */ export class SpreadsheetPivotModel extends PivotModel { /** * @param {Object} params * @param {PivotMetaData} params.metaData * @param {PivotSearchParams} params.searchParams * @param {Object} services * @param {import("../data_sources/metadata_repository").MetadataRepository} services.metadataRepository */ setup(params, services) { // fieldAttrs is required, but not needed in Spreadsheet, so we define it as empty (params.metaData.fieldAttrs = {}), super.setup(params); this.metadataRepository = services.metadataRepository; /** * Contains the domain of the values used during the evaluation of the formula =Pivot(...) * Is used to know if a pivot cell is missing or not * */ this._usedValueDomains = new Set(); /** * Contains the domain of the headers used during the evaluation of the formula =Pivot.header(...) * Is used to know if a pivot cell is missing or not * */ this._usedHeaderDomains = new Set(); /** * Display name of the model */ this._modelLabel = params.metaData.modelLabel; } //-------------------------------------------------------------------------- // Metadata getters //-------------------------------------------------------------------------- /** * Return true if the given field name is part of the col group bys * @param {string} fieldName * @returns {boolean} */ isColumnGroupBy(fieldName) { try { const { field } = this.parseGroupField(fieldName); return this._isCol(field); } catch (_) { return false; } } /** * Return true if the given field name is part of the row group bys * @param {string} fieldName * @returns {boolean} */ isRowGroupBy(fieldName) { try { const { field } = this.parseGroupField(fieldName); return this._isRow(field); } catch (_) { return false; } } /** * Get the display name of a group by * @param {string} fieldName * @returns {string} */ getFormattedGroupBy(fieldName) { const { field, aggregateOperator } = this.parseGroupField(fieldName); return field.string + (aggregateOperator ? ` (${PERIODS[aggregateOperator]})` : ""); } getReportMeasures() { return computeReportMeasures(this.metaData.fields, this.metaData.fieldAttrs, []); } //-------------------------------------------------------------------------- // Cell missing //-------------------------------------------------------------------------- /** * Reset the used values and headers */ clearUsedValues() { this._usedHeaderDomains.clear(); this._usedValueDomains.clear(); } /** * Check if the given domain with the given measure has been used */ isUsedValue(domain, measure) { const tag = [measure, ...domain]; return this._usedValueDomains.has(tag.join()); } /** * Check if the given domain has been used */ isUsedHeader(domain) { return this._usedHeaderDomains.has(domain.join()); } /** * Indicate that the given domain has been used with the given measure */ markAsValueUsed(domain, measure) { const toTag = [measure, ...domain]; this._usedValueDomains.add(toTag.join()); } /** * Indicate that the given domain has been used */ markAsHeaderUsed(domain) { this._usedHeaderDomains.add(domain.join()); } //-------------------------------------------------------------------------- // Autofill //-------------------------------------------------------------------------- /** * @param {string} dimension COLUMN | ROW */ isGroupedOnlyByOneDate(dimension) { const groupBys = dimension === "COLUMN" ? this.metaData.fullColGroupBys : this.metaData.fullRowGroupBys; return groupBys.length === 1 && this._isDateField(this.parseGroupField(groupBys[0]).field); } /** * @param {string} dimension COLUMN | ROW */ getGroupOfFirstDate(dimension) { if (!this.isGroupedOnlyByOneDate(dimension)) { return undefined; } const groupBys = dimension === "COLUMN" ? this.metaData.fullColGroupBys : this.metaData.fullRowGroupBys; return this.parseGroupField(groupBys[0]).aggregateOperator; } /** * @param {string} dimension COLUMN | ROW * @param {number} index */ getGroupByAtIndex(dimension, index) { const groupBys = dimension === "COLUMN" ? this.metaData.fullColGroupBys : this.metaData.fullRowGroupBys; return groupBys[index]; } getNumberOfColGroupBys() { return this.metaData.fullColGroupBys.length; } //-------------------------------------------------------------------------- // Evaluation //-------------------------------------------------------------------------- /** * Get the value of the given domain for the given measure */ getPivotCellValue(measure, domain) { const { cols, rows } = this._getColsRowsValuesFromDomain(domain); const group = JSON.stringify([rows, cols]); const values = this.data.measurements[group]; return (values && values[0][measure]) || ""; } /** * Get the label the given field-value * * @param {string} groupFieldString Name of the field * @param {string} groupValueString Value of the group by * @returns {string} */ getGroupByDisplayLabel(groupFieldString, groupValueString) { if (groupValueString === NO_RECORD_AT_THIS_POSITION) { return ""; } if (groupFieldString === "measure") { if (groupValueString === "__count") { return _t("Count"); } // the value is actually the measure field name return this.parseGroupField(groupValueString).field.string; } const { field, aggregateOperator } = this.parseGroupField(groupFieldString); const value = parsePivotFormulaFieldValue(field, groupValueString); const undef = _t("None"); if (this._isDateField(field)) { if (value && aggregateOperator === "day") { return toNumber(value); } return formatDate(aggregateOperator, value); } if (field.relation) { const label = this.metadataRepository.getRecordDisplayName(field.relation, value); if (!label) { return undef; } return label; } const label = this.metadataRepository.getLabel(this.metaData.resModel, field.name, value); if (!label) { return undef; } return label; } /** * Get the label of the last group by of the domain * * @param {any[]} domain Domain of the formula */ getPivotHeaderValue(domain) { const groupFieldString = domain[domain.length - 2]; if (groupFieldString.startsWith("#")) { const { field } = this.parseGroupField(groupFieldString); const { cols, rows } = this._getColsRowsValuesFromDomain(domain); return this._isCol(field) ? cols[cols.length - 1] : rows[rows.length - 1]; } return domain[domain.length - 1]; } /** * Get the displayed label of the last group by of the domain * * @param {string[]} domain Domain of the formula * @returns {string} */ getDisplayedPivotHeaderValue(domain) { const groupFieldString = domain[domain.length - 2]; return this.getGroupByDisplayLabel(groupFieldString, this.getPivotHeaderValue(domain)); } //-------------------------------------------------------------------------- // Misc //-------------------------------------------------------------------------- /** * Get the Odoo domain corresponding to the given domain */ getPivotCellDomain(domain) { const { cols, rows } = this._getColsRowsValuesFromDomain(domain); const key = JSON.stringify([rows, cols]); const domains = this.data.groupDomains[key]; return domains ? domains[0] : Domain.FALSE.toList(); } /** * @returns {SpreadsheetPivotTable} */ getTableStructure() { const cols = this._getSpreadsheetCols(); const rows = this._getSpreadsheetRows(this.data.rowGroupTree); rows.push(rows.shift()); //Put the Total row at the end. const measures = this.metaData.activeMeasures; return new SpreadsheetPivotTable(cols, rows, measures); } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ async _loadData(config) { /** @type {(groupFieldString: string) => ReturnType} */ this.parseGroupField = parseGroupField.bind(null, this.metaData.fields); /* * prune is manually set to false in order to expand all the groups * automatically */ const prune = false; await super._loadData(config, prune); const metadataRepository = this.metadataRepository; const registerLabels = (tree, groupBys) => { const group = tree.root; if (!tree.directSubTrees.size) { for (let i = 0; i < group.values.length; i++) { const { field } = this.parseGroupField(groupBys[i]); if (!field.relation) { metadataRepository.registerLabel( config.metaData.resModel, field.name, group.values[i], group.labels[i] ); } else { metadataRepository.setDisplayName( field.relation, group.values[i], group.labels[i] ); } } } [...tree.directSubTrees.values()].forEach((subTree) => { registerLabels(subTree, groupBys); }); }; registerLabels(this.data.colGroupTree, this.metaData.fullColGroupBys); registerLabels(this.data.rowGroupTree, this.metaData.fullRowGroupBys); } /** * Determines if the given field is a date or datetime field. * * @param {Field} field Field description * @private * @returns {boolean} True if the type of the field is date or datetime */ _isDateField(field) { return ["date", "datetime"].includes(field.type); } /** * @override */ _getGroupValues(group, groupBys) { return groupBys.map((groupBy) => { const { field, aggregateOperator } = this.parseGroupField(groupBy); if (this._isDateField(field)) { const value = this._getGroupStartingDay(groupBy, group); if (!value) { return false; } const fOut = FORMATS[aggregateOperator]["out"]; // eslint-disable-next-line no-undef let date = moment(value); if (aggregateOperator === "week") { date = date.endOf("week"); } return date.isValid() ? date.format(fOut) : false; } return this._sanitizeValue(group[groupBy]); }); } /** * When grouping by a time field, return * the group starting day (local to the timezone) * @param {string} groupBy * @param {object} readGroup * @returns {string | undefined} */ _getGroupStartingDay(groupBy, readGroup) { if (!readGroup["__range"] || !readGroup["__range"][groupBy]) { return undefined; } const { field } = this.parseGroupField(groupBy); const sqlValue = readGroup["__range"][groupBy].from; if (this.metaData.fields[field.name].type === "date") { return sqlValue; } const userTz = session.user_context.tz || luxon.Settings.defaultZoneName; return luxon.DateTime.fromSQL(sqlValue, { zone: "utc" }).setZone(userTz).toISODate(); } /** * Check if the given field is used as col group by */ _isCol(field) { return this.metaData.fullColGroupBys .map(this.parseGroupField) .map(({ field }) => field.name) .includes(field.name); } /** * Check if the given field is used as row group by */ _isRow(field) { return this.metaData.fullRowGroupBys .map(this.parseGroupField) .map(({ field }) => field.name) .includes(field.name); } /** * Get the value of a field-value for a positional group by * * @param {object} field Field of the group by * @param {unknown} groupValueString Value of the group by * @param {(number | boolean | string)[]} rows Values for the previous row group bys * @param {(number | boolean | string)[]} cols Values for the previous col group bys * * @private * @returns {number | boolean | string} */ _parsePivotFormulaWithPosition(field, groupValueString, cols, rows) { const position = toNumber(groupValueString) - 1; let tree; if (this._isCol(field)) { tree = this.data.colGroupTree; for (const col of cols) { tree = tree && tree.directSubTrees.get(col); } } else { tree = this.data.rowGroupTree; for (const row of rows) { tree = tree && tree.directSubTrees.get(row); } } if (tree) { const treeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()]; const sortedKey = treeKeys[position]; return sortedKey !== undefined ? sortedKey : NO_RECORD_AT_THIS_POSITION; } return NO_RECORD_AT_THIS_POSITION; } /** * Transform the given domain in the structure used in this class * * @param {(number | boolean | string)[]} domain Domain * * @private */ _getColsRowsValuesFromDomain(domain) { const rows = []; const cols = []; let i = 0; while (i < domain.length) { const groupFieldString = domain[i]; const groupValue = domain[i + 1]; const { field, isPositional } = this.parseGroupField(groupFieldString); let value; if (isPositional) { value = this._parsePivotFormulaWithPosition(field, groupValue, cols, rows); } else { value = parsePivotFormulaFieldValue(field, groupValue); } if (this._isCol(field)) { cols.push(value); } else if (this._isRow(field)) { rows.push(value); } i += 2; } return { rows, cols }; } /** * Get the row structure * @returns {Row[]} */ _getSpreadsheetRows(tree) { /**@type {Row[]}*/ const rows = []; const group = tree.root; const indent = group.labels.length; const rowGroupBys = this.metaData.fullRowGroupBys; rows.push({ fields: rowGroupBys.slice(0, indent), values: group.values.map((val) => val.toString()), indent, }); const subTreeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()]; subTreeKeys.forEach((subTreeKey) => { const subTree = tree.directSubTrees.get(subTreeKey); rows.push(...this._getSpreadsheetRows(subTree)); }); return rows; } /** * Get the col structure * @returns {Column[][]} */ _getSpreadsheetCols() { const colGroupBys = this.metaData.fullColGroupBys; const height = colGroupBys.length; const measureCount = this.metaData.activeMeasures.length; const leafCounts = this._getLeafCounts(this.data.colGroupTree); const headers = new Array(height).fill(0).map(() => []); function generateTreeHeaders(tree, fields) { const group = tree.root; const rowIndex = group.values.length; if (rowIndex !== 0) { const row = headers[rowIndex - 1]; const leafCount = leafCounts[JSON.stringify(tree.root.values)]; const cell = { fields: colGroupBys.slice(0, rowIndex), values: group.values.map((val) => val.toString()), width: leafCount * measureCount, }; row.push(cell); } [...tree.directSubTrees.values()].forEach((subTree) => { generateTreeHeaders(subTree, fields); }); } generateTreeHeaders(this.data.colGroupTree, this.metaData.fields); const hasColGroupBys = this.metaData.colGroupBys.length; // 2) generate measures row const measureRow = []; if (hasColGroupBys) { headers[headers.length - 1].forEach((cell) => { this.metaData.activeMeasures.forEach((measureName) => { const measureCell = { fields: [...cell.fields, "measure"], values: [...cell.values, measureName], width: 1, }; measureRow.push(measureCell); }); }); } this.metaData.activeMeasures.forEach((measureName) => { const measureCell = { fields: ["measure"], values: [measureName], width: 1, }; measureRow.push(measureCell); }); headers.push(measureRow); // 3) Add the total cell if (headers.length === 1) { headers.unshift([]); // Will add the total there } headers[headers.length - 2].push({ fields: [], values: [], width: this.metaData.activeMeasures.length, }); return headers; } }