mirror of
https://github.com/bringout/oca-ocb-report.git
synced 2026-04-21 15:02:01 +02:00
668 lines
22 KiB
JavaScript
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;
|
|
}
|
|
}
|