oca-ocb-report/odoo-bringout-oca-ocb-spreadsheet/spreadsheet/static/src/pivot/pivot_model.js
2025-08-29 15:20:51 +02:00

668 lines
22 KiB
JavaScript

/** @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<string>} colGroupBys
* @property {Array<string>} rowGroupBys
* @property {Array<string>} activeMeasures
* @property {string} resModel
* @property {Record<string, Field>} fields
* @property {string|undefined} modelLabel
*
* @typedef {Object} PivotSearchParams
* @property {Array<string>} groupBy
* @property {Array<string>} 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<string, Field>} 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<parseGroupField>} */
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;
}
}