Odoo18-Base/addons/spreadsheet/static/src/pivot/pivot_model.js
2025-01-06 10:57:38 +07:00

652 lines
23 KiB
JavaScript

//@ts-check
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 { helpers, constants, EvaluationError, SpreadsheetPivotTable } from "@odoo/o-spreadsheet";
import { parseGroupField } from "./pivot_helpers";
const { toNormalizedPivotValue, toNumber, isDateOrDatetimeField, pivotTimeAdapter } = helpers;
const { DEFAULT_LOCALE } = constants;
/**
* @typedef {import("@odoo/o-spreadsheet").PivotTableColumn} PivotTableColumn
* @typedef {import("@odoo/o-spreadsheet").PivotTableRow} PivotTableRow
* @typedef {import("@odoo/o-spreadsheet").PivotDomain} PivotDomain
* @typedef {import("@odoo/o-spreadsheet").PivotMeasure} PivotMeasure
*/
export const NO_RECORD_AT_THIS_POSITION = "__NO_RECORD_AT_THIS_POSITION__";
/**
* This class is an extension of PivotModel with some additional information
* that we need in spreadsheet (display_name, isUsedInSheet, ...)
*/
export class OdooPivotModel extends PivotModel {
/**
* @param {import("@web/env").OdooEnv} env
* @param {import("@spreadsheet").OdooPivotModelParams} params
* @param {import("@spreadsheet").PivotModelServices} services
*/
constructor(env, params, services) {
super(env, params, services);
/**
* @private
*/
this._displayNames = {};
/**
* @private
*/
this._displayLabels = {};
/**
* @private
* @type {import("@spreadsheet/data_sources/server_data").ServerData}
*/
this.serverData = services.serverData;
}
/**
* @param {import("@spreadsheet").OdooPivotModelParams} params
* @param {import("@spreadsheet").PivotModelServices} services
*/
setup(params, services) {
/** This is necessary to ensure the compatibility with the PivotModel from web */
const p = params.definition.getDefinitionForPivotModel(params.fields);
p.searchParams = {
...p.searchParams,
...params.searchParams,
};
super.setup(p);
this.definition = params.definition;
}
/**
* Update the parts of the pivot measures that do not impact data fetching
* (do not update fieldName or aggregate).
* @param {PivotMeasure[]} measures
*/
updateMeasures(measures) {
for (const measure of this.definition.measures) {
const updatedMeasure = measures.find((m) => m.id === measure.id);
if (!updatedMeasure || updatedMeasure.computedBy) {
continue;
}
if (
updatedMeasure.fieldName !== measure.fieldName ||
updatedMeasure.aggregator !== measure.aggregator
) {
throw new Error("Measures fieldName or aggregator cannot be updated");
}
}
this.definition.measures = measures;
this.resetTableStructure();
}
getDefinition() {
return this.definition;
}
async load(searchParams) {
if (
this.metaData.activeMeasures.find(
(fieldName) => fieldName !== "__count" && !this.metaData.fields[fieldName]
)
) {
throw new Error(
_t(
"Some measures are not available: %s",
this.metaData.activeMeasures
.filter((fieldName) => !this.metaData.fields[fieldName])
.join(", ")
)
);
}
searchParams.groupBy = [];
searchParams.orderBy = [];
await super.load(searchParams);
}
//--------------------------------------------------------------------------
// Evaluation
//--------------------------------------------------------------------------
/**
* Get the value of the given domain for the given measure
* @param {PivotMeasure} measure
* @param {PivotDomain} domain
*/
getPivotCellValue(measure, domain) {
if (domain.some((node) => node.value === NO_RECORD_AT_THIS_POSITION)) {
return "";
}
const { cols, rows } = this._getColsRowsValuesFromDomain(domain);
const group = JSON.stringify([rows, cols]);
const values = this.data.measurements[group];
const measurementId = this._computeMeasurementId(measure);
if (values && (values[0][measurementId] || values[0][measurementId] === 0)) {
return values[0][measurementId];
}
return "";
}
/**
* Get the value of a field
*
* @example
* getGroupByCellValue("stage_id", 42) // "Won"
*
* @param {string} groupFieldString Name of the field
* @param {string | number | boolean} groupValueString Value of the group by
* @returns {string | number | boolean}
*/
getGroupByCellValue(groupFieldString, groupValueString) {
if (groupValueString === NO_RECORD_AT_THIS_POSITION) {
return "";
}
const { field, granularity, dimensionWithGranularity } =
this.parseGroupField(groupFieldString);
const dimension = this.definition.getDimension(dimensionWithGranularity);
const value = toNormalizedPivotValue(dimension, groupValueString);
const undef = _t("None");
if (isDateOrDatetimeField(field)) {
const adapter = pivotTimeAdapter(granularity);
return adapter.toValueAndFormat(value).value;
}
if (field.relation) {
if (value === false) {
return undef;
}
return this._getRelationalDisplayName(field.relation, value);
}
const label = this._displayLabels[field.name]?.[value];
if (!label) {
return undef;
}
return label;
}
/**
* Get the value of the last group by of the function arguments
* e.g. in `PIVOT.HEADER(1, "stage_id", "42", "status", "won")`
* the last group value is "won".
*
* It can also handle positional arguments.
* e.g. in `PIVOT.HEADER(1, "#stage_id", 1, "#user_id", 1)`
* the last group value is the id of the first user of the first stage.
*
* @param {PivotDomain} domain PIVOT.HEADER arguments
* @returns {string | boolean | number}
*/
getLastPivotGroupValue(domain) {
const lastNode = domain.at(-1);
if (!lastNode) {
throw new Error("Domain size should be at least 1");
}
if (lastNode.field.startsWith("#")) {
if (domain.filter((node) => node.value === NO_RECORD_AT_THIS_POSITION).length) {
return NO_RECORD_AT_THIS_POSITION;
}
const { dimensionWithGranularity } = this.parseGroupField(lastNode.field);
const { cols, rows } = this._getColsRowsValuesFromDomain(domain);
return this._isCol(dimensionWithGranularity) ? cols.at(-1) : rows.at(-1);
}
return lastNode.value;
}
//--------------------------------------------------------------------------
// Misc
//--------------------------------------------------------------------------
/**
* Get the Odoo domain corresponding to the given domain
* @param {PivotDomain} domain
*/
getPivotCellDomain(domain) {
if (domain.some((node) => node.value === NO_RECORD_AT_THIS_POSITION)) {
return undefined;
}
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();
}
resetTableStructure() {
this._tableStructure = undefined;
}
getTableStructure() {
if (this._tableStructure === undefined) {
// lazy build the structure
this._tableStructure = this._buildTableStructure();
}
return this._tableStructure;
}
/**
* @param {import("@odoo/o-spreadsheet").PivotDimension} dimension
* @returns {{ value: string | number | boolean, label: string }[]}
*/
getPossibleFieldValues(dimension) {
const valuesWithLabels = [];
const valuesUniqueness = new Set();
const isCol = this._isCol(dimension.nameWithGranularity);
const groupBys = isCol ? this.definition.columns : this.definition.rows;
const tree = isCol ? this.data.colGroupTree : this.data.rowGroupTree;
const groupByIndex = groupBys.findIndex(
(d) => d.nameWithGranularity === dimension.nameWithGranularity
);
const visitTree = (tree) => {
const { values, labels } = tree.root;
const value = values[groupByIndex];
if (value !== undefined && !valuesUniqueness.has(value)) {
valuesUniqueness.add(value);
valuesWithLabels.push({
value: value,
label: labels[groupByIndex].toString(),
});
}
[...tree.directSubTrees.values()].forEach((subTree) => {
visitTree(subTree);
});
};
visitTree(tree);
return valuesWithLabels;
}
/**
* @returns {SpreadsheetPivotTable}
*/
_buildTableStructure() {
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.getDefinition()
.measures.filter((measure) => !measure.isHidden)
.map((measure) => measure.id);
/** @type {Record<string, string | undefined>} */
const fieldsType = {};
for (const columns of this.getDefinition().columns) {
fieldsType[columns.fieldName] = columns.type;
}
for (const row of this.getDefinition().rows) {
fieldsType[row.fieldName] = row.type;
}
return new SpreadsheetPivotTable(cols, rows, measures, fieldsType);
}
//--------------------------------------------------------------------------
// 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 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) {
this._registerDisplayLabel(field.name, group.values[i], group.labels[i]);
} else {
const id = group.values[i];
const displayName = group.labels[i];
this._registerDisplayName(field.relation, id, displayName);
}
}
}
[...tree.directSubTrees.values()].forEach((subTree) => {
registerLabels(subTree, groupBys);
});
};
registerLabels(this.data.colGroupTree, this.metaData.fullColGroupBys);
registerLabels(this.data.rowGroupTree, this.metaData.fullRowGroupBys);
}
_registerDisplayLabel(fieldName, value, label) {
if (!this._displayLabels[fieldName]) {
this._displayLabels[fieldName] = {};
}
this._displayLabels[fieldName][value] = label;
}
_registerDisplayName(resModel, resId, displayName) {
if (!this._displayNames[resModel]) {
this._displayNames[resModel] = {};
}
this._displayNames[resModel][resId] = displayName;
}
_getRelationalDisplayName(resModel, resId) {
const displayName =
this._displayNames[resModel]?.[resId] ||
this.serverData.batch.get("spreadsheet.mixin", "get_display_names_for_spreadsheet", {
model: resModel,
id: resId,
});
if (!displayName) {
throw new EvaluationError(
_t("Unable to fetch the label of %(id)s of model %(model)s", {
id: resId,
model: resModel,
})
);
}
return displayName;
}
_normalize(groupBy) {
const [fieldName] = groupBy.split(":");
const field = this.metaData.fields[fieldName];
if (!field) {
throw new EvaluationError(_t("Field %s does not exist", fieldName));
}
return super._normalize(groupBy);
}
/**
* @override
*/
_getGroupValues(group, groupBys) {
return groupBys.map((gb) => {
const groupBy = this._normalize(gb);
const { field, granularity } = this.parseGroupField(gb);
if (isDateOrDatetimeField(field)) {
return pivotTimeAdapter(granularity).normalizeServerValue(groupBy, field, group);
}
return this._sanitizeValue(group[groupBy]);
});
}
/**
* Check if the given field is used as col group by
*/
_isCol(nameWithGranularity) {
return this.metaData.fullColGroupBys.includes(nameWithGranularity);
}
/**
* Check if the given field is used as row group by
*/
_isRow(nameWithGranularity) {
return this.metaData.fullRowGroupBys.includes(nameWithGranularity);
}
/**
* Get the value of a field-value for a positional group by
*
* @param {string} dimensionWithGranularity e.g. create_date:month
* @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(dimensionWithGranularity, groupValueString, cols, rows) {
const position = toNumber(groupValueString, DEFAULT_LOCALE) - 1;
let tree;
if (this._isCol(dimensionWithGranularity)) {
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 {PivotDomain} domain Domain
*
* @private
*/
_getColsRowsValuesFromDomain(domain) {
const rows = [];
const cols = [];
for (const node of domain) {
const { isPositional, dimensionWithGranularity } = this.parseGroupField(node.field);
let value;
if (isPositional) {
value = this._parsePivotFormulaWithPosition(
dimensionWithGranularity,
node.value,
cols,
rows
);
} else {
const dimension = this.definition.getDimension(dimensionWithGranularity);
value = toNormalizedPivotValue(dimension, node.value);
}
if (this._isCol(dimensionWithGranularity)) {
cols.push(value);
} else if (this._isRow(dimensionWithGranularity)) {
rows.push(value);
} else {
throw new EvaluationError(
sprintf(_t("Dimension %s is not a group by"), dimensionWithGranularity)
);
}
}
return { rows, cols };
}
/**
* Get the row structure
* @returns {PivotTableRow[]}
*/
_getSpreadsheetRows(tree) {
/**@type {PivotTableRow[]}*/
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 {PivotTableColumn[][]}
*/
_getSpreadsheetCols() {
const colGroupBys = this.metaData.fullColGroupBys;
const height = colGroupBys.length;
const measures = this.getDefinition().measures.filter((measure) => !measure.isHidden);
const measureCount = measures.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) => {
measures.forEach((measure) => {
const measureCell = {
fields: [...cell.fields, "measure"],
values: [...cell.values, measure.id],
width: 1,
};
measureRow.push(measureCell);
});
});
}
measures.forEach((measure) => {
const measureCell = {
fields: ["measure"],
values: [measure.id],
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: measures.length,
});
return headers;
}
/**
* @override
* @protected
* @return {string[]}
*/
_getMeasureSpecs() {
return this.getDefinition()
.measures.filter((measure) => !measure.computedBy)
.map((measure) => {
const measurementId = `${measure.fieldName}_${measure.aggregator}_id`;
if (measure.type === "many2one" && !measure.aggregator) {
return `${measure.fieldName}:count_distinct`;
}
if (measure.fieldName === "__count") {
// Remove aggregator that is not supported by python
return "__count";
}
return measure.aggregator
? `${measurementId}:${measure.aggregator}(${measure.fieldName})`
: measure.fieldName;
});
}
/**
* @override to add the order by clause to the read_group kwargs
*/
_getSubGroups(groupBys, params) {
const { columns, rows } = this.getDefinition();
const order = columns
.concat(rows)
.filter(
(dimension) => dimension.order && groupBys.includes(dimension.nameWithGranularity)
)
.map((dimension) => `${dimension.nameWithGranularity} ${dimension.order}`)
.join(",");
params.kwargs.orderby = order;
return super._getSubGroups(groupBys, params);
}
/**
* This method is used to compute the identifier of a measurement in the
* data of the web model. It's needed since we support to define an
* aggregator for a field.
*/
_computeMeasurementId(measure) {
if (measure.fieldName === "__count") {
return "__count";
}
if (measure.aggregator) {
return `${measure.fieldName}_${measure.aggregator}_id`;
}
return measure.fieldName;
}
/**
* Override to support multiple aggregators for a same field
*
* @override
*/
_getMeasurements(group) {
return this.getDefinition()
.measures.filter((measure) => !measure.computedBy)
.reduce((measurements, measure) => {
const measurementId = this._computeMeasurementId(measure);
var measurement = group[measurementId];
if (measurement instanceof Array) {
// case field is many2one and used as measure and groupBy simultaneously
measurement = 1;
}
if (measure.type === "boolean" && measurement instanceof Boolean) {
measurement = measurement ? 1 : 0;
}
measurements[measurementId] = measurement;
return measurements;
}, {});
}
/**
* Override to support multiple aggregators for a same field
*
* @override
*/
_getCellValue(groupId, measureName, originIndexes, config) {
const measure = this.getDefinition().measures.find((m) => m.fieldName === measureName);
const measurementId = this._computeMeasurementId(measure);
var key = JSON.stringify(groupId);
if (!config.data.measurements[key]) {
return;
}
var values = originIndexes.map((originIndex) => {
return config.data.measurements[key][originIndex][measurementId];
});
return values[0];
}
}