Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,811 @@
// Fusion Accounting - Account Report Controller
// Copyright (C) 2026 Nexa Systems Inc.
/* global owl:readonly */
import { browser } from "@web/core/browser/browser";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { removeTaxGroupingFromLineId } from "@fusion_accounting/js/util";
/**
* AccountReportController - Core controller managing report data, options,
* and server communication. Handles loading report data, applying filters,
* managing line expansion/collapse state, and dispatching report actions.
*/
export class AccountReportController {
constructor(action) {
this.action = action;
this.actionService = useService("action");
this.dialog = useService("dialog");
this.orm = useService("orm");
}
async load(env) {
this.env = env;
this.reportOptionsMap = {};
this.reportInformationMap = {};
this.lastOpenedSectionByReport = {};
this.loadingCallNumberByCacheKey = new Proxy(
{},
{
get(target, name) {
return name in target ? target[name] : 0;
},
set(target, name, newValue) {
target[name] = newValue;
return true;
},
}
);
this.actionReportId = this.action.context.report_id;
const isOpeningReport = !this.action?.keep_journal_groups_options // true when opening the report, except when coming from the breadcrumb
const mainReportOptions = await this.loadReportOptions(this.actionReportId, false, this.action.params?.ignore_session, isOpeningReport);
const cacheKey = this.getCacheKey(mainReportOptions['sections_source_id'], mainReportOptions['report_id']);
// We need the options to be set and saved in order for the loading to work properly
this.options = mainReportOptions;
this.reportOptionsMap[cacheKey] = mainReportOptions;
this.incrementCallNumber(cacheKey);
this.options["loading_call_number"] = this.loadingCallNumberByCacheKey[cacheKey];
this.saveSessionOptions(mainReportOptions);
const activeSectionPromise = this.displayReport(mainReportOptions['report_id']);
this.preLoadClosedSections();
await activeSectionPromise;
}
getCacheKey(sectionsSourceId, reportId) {
return `${sectionsSourceId}_${reportId}`
}
incrementCallNumber(cacheKey = null) {
if (!cacheKey) {
cacheKey = this.getCacheKey(this.options['sections_source_id'], this.options['report_id']);
}
this.loadingCallNumberByCacheKey[cacheKey] += 1;
}
async displayReport(reportId) {
const cacheKey = await this.loadReport(reportId);
const options = await this.reportOptionsMap[cacheKey];
const informationMap = await this.reportInformationMap[cacheKey];
if (
options !== undefined
&& this.loadingCallNumberByCacheKey[cacheKey] === options["loading_call_number"]
&& (this.lastOpenedSectionByReport === {} || this.lastOpenedSectionByReport[options['selected_variant_id']] === options['selected_section_id'])
) {
// the options gotten from the python correspond to the ones that called this displayReport
this.options = options;
// informationMap might be undefined if the promise has been deleted by another call.
// Don't need to set data, the call that deleted it is coming to re-put data
if (informationMap !== undefined) {
this.data = informationMap;
// If there is a specific order for lines in the options, we want to use it by default
if (this.areLinesOrdered()) {
await this.sortLines();
}
this.setLineVisibility(this.lines);
this.refreshVisibleAnnotations();
this.saveSessionOptions(this.options);
}
}
}
async reload(optionPath, newOptions) {
const rootOptionKey = optionPath ? optionPath.split(".")[0] : "";
/*
When reloading the UI after setting an option filter, invalidate the cached options and data of all sections supporting this filter.
This way, those sections will be reloaded (either synchronously when the user tries to access them or asynchronously via the preloading
feature), and will then use the new filter value. This ensures the filters are always applied consistently to all sections.
*/
for (const [cacheKey, cachedOptionsPromise] of Object.entries(this.reportOptionsMap)) {
let cachedOptions = await cachedOptionsPromise;
if (rootOptionKey === "" || cachedOptions.hasOwnProperty(rootOptionKey)) {
delete this.reportOptionsMap[cacheKey];
delete this.reportInformationMap[cacheKey];
}
}
this.saveSessionOptions(newOptions); // The new options will be loaded from the session. Saving them now ensures the new filter is taken into account.
await this.displayReport(newOptions['report_id']);
}
async preLoadClosedSections() {
let sectionLoaded = false;
for (const section of this.options['sections']) {
// Preload the first non-loaded section we find amongst this report's sections.
const cacheKey = this.getCacheKey(this.options['sections_source_id'], section.id);
if (section.id != this.options['report_id'] && !this.reportInformationMap[cacheKey]) {
await this.loadReport(section.id, true);
sectionLoaded = true;
// Stop iterating and schedule next call. We don't go on in the loop in case the cache is reset and we need to restart preloading.
break;
}
}
let nextCallDelay = (sectionLoaded) ? 100 : 1000;
const self = this;
setTimeout(() => self.preLoadClosedSections(), nextCallDelay);
}
async loadReport(reportId, preloading=false) {
const options = await this.loadReportOptions(reportId, preloading, false); // This also sets the promise in the cache
const reportToDisplayId = options['report_id']; // Might be different from reportId, in case the report to open uses sections
const cacheKey = this.getCacheKey(options['sections_source_id'], reportToDisplayId)
if (!this.reportInformationMap[cacheKey]) {
this.reportInformationMap[cacheKey] = this.orm.call(
"account.report",
options.readonly_query ? "get_report_information_readonly" : "get_report_information",
[
reportToDisplayId,
options,
],
{
context: this.action.context,
},
);
}
await this.reportInformationMap[cacheKey];
if (!preloading) {
if (options['sections'].length)
this.lastOpenedSectionByReport[options['sections_source_id']] = options['selected_section_id'];
}
return cacheKey;
}
async loadReportOptions(reportId, preloading=false, ignore_session=false, isOpeningReport=false) {
const loadOptions = (ignore_session || !this.hasSessionOptions()) ? (this.action.params?.options || {}) : this.sessionOptions();
const cacheKey = this.getCacheKey(loadOptions['sections_source_id'] || reportId, reportId);
if (!(cacheKey in this.loadingCallNumberByCacheKey)) {
this.incrementCallNumber(cacheKey);
}
loadOptions["loading_call_number"] = this.loadingCallNumberByCacheKey[cacheKey];
loadOptions["is_opening_report"] = isOpeningReport;
if (!this.reportOptionsMap[cacheKey]) {
// The options for this section are not loaded nor loading. Let's load them !
if (preloading)
loadOptions['selected_section_id'] = reportId;
else {
/* Reopen the last opened section by default (cannot be done through regular caching, because composite reports' options are not
cached (since they always reroute). */
if (this.lastOpenedSectionByReport[reportId])
loadOptions['selected_section_id'] = this.lastOpenedSectionByReport[reportId];
}
this.reportOptionsMap[cacheKey] = this.orm.call(
"account.report",
"get_options",
[
reportId,
loadOptions,
],
{
context: this.action.context,
},
);
// Wait for the result, and check the report hasn't been rerouted to a section or variant; fix the cache if it has
let reportOptions = await this.reportOptionsMap[cacheKey];
// In case of a reroute, also set the cached options into the reroute target's key
const loadedOptionsCacheKey = this.getCacheKey(reportOptions['sections_source_id'], reportOptions['report_id']);
if (loadedOptionsCacheKey !== cacheKey) {
/* We delete the rerouting report from the cache, to avoid redoing this reroute when reloading the cached options, as it would mean
route reports can never be opened directly if they open some variant by default.*/
delete this.reportOptionsMap[cacheKey];
this.reportOptionsMap[loadedOptionsCacheKey] = reportOptions;
this.loadingCallNumberByCacheKey[loadedOptionsCacheKey] = 1;
delete this.loadingCallNumberByCacheKey[cacheKey];
return reportOptions;
}
}
return this.reportOptionsMap[cacheKey];
}
//------------------------------------------------------------------------------------------------------------------
// Generic data getters
//------------------------------------------------------------------------------------------------------------------
get buttons() {
return this.options.buttons;
}
get caretOptions() {
return this.data.caret_options;
}
get columnHeadersRenderData() {
return this.data.column_headers_render_data;
}
get columnGroupsTotals() {
return this.data.column_groups_totals;
}
get context() {
return this.data.context;
}
get filters() {
return this.data.filters;
}
get annotations() {
return this.data.annotations;
}
get groups() {
return this.data.groups;
}
get lines() {
return this.data.lines;
}
get warnings() {
return this.data.warnings;
}
get linesOrder() {
return this.data.lines_order;
}
get report() {
return this.data.report;
}
get visibleAnnotations() {
return this.data.visible_annotations;
}
//------------------------------------------------------------------------------------------------------------------
// Generic data setters
//------------------------------------------------------------------------------------------------------------------
set annotations(value) {
this.data.annotations = value;
}
set columnGroupsTotals(value) {
this.data.column_groups_totals = value;
}
set lines(value) {
this.data.lines = value;
this.setLineVisibility(this.lines);
}
set linesOrder(value) {
this.data.lines_order = value;
}
set visibleAnnotations(value) {
this.data.visible_annotations = value;
}
//------------------------------------------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------------------------------------------
get needsColumnPercentComparison() {
return this.options.column_percent_comparison === "growth";
}
get hasCustomSubheaders() {
return this.columnHeadersRenderData.custom_subheaders.length > 0;
}
get hasDebugColumn() {
return Boolean(this.options.show_debug_column);
}
get hasStringDate() {
return "date" in this.options && "string" in this.options.date;
}
get hasVisibleAnnotations() {
return Boolean(this.visibleAnnotations.length);
}
//------------------------------------------------------------------------------------------------------------------
// Options
//------------------------------------------------------------------------------------------------------------------
async _updateOption(operationType, optionPath, optionValue=null, reloadUI=false) {
const optionKeys = optionPath.split(".");
let currentOptionKey = null;
let option = this.options;
while (optionKeys.length > 1) {
currentOptionKey = optionKeys.shift();
option = option[currentOptionKey];
if (option === undefined)
throw new Error(`Invalid option key in _updateOption(): ${ currentOptionKey } (${ optionPath })`);
}
switch (operationType) {
case "update":
option[optionKeys[0]] = optionValue;
break;
case "delete":
delete option[optionKeys[0]];
break;
case "toggle":
option[optionKeys[0]] = !option[optionKeys[0]];
break;
default:
throw new Error(`Invalid operation type in _updateOption(): ${ operationType }`);
}
if (reloadUI) {
this.incrementCallNumber();
await this.reload(optionPath, this.options);
}
}
async updateOption(optionPath, optionValue, reloadUI=false) {
await this._updateOption('update', optionPath, optionValue, reloadUI);
}
async deleteOption(optionPath, reloadUI=false) {
await this._updateOption('delete', optionPath, null, reloadUI);
}
async toggleOption(optionPath, reloadUI=false) {
await this._updateOption('toggle', optionPath, null, reloadUI);
}
async switchToSection(reportId) {
this.saveSessionOptions({...this.options, 'selected_section_id': reportId});
this.displayReport(reportId);
}
//------------------------------------------------------------------------------------------------------------------
// Session options
//------------------------------------------------------------------------------------------------------------------
sessionOptionsID() {
/* Options are stored by action report (so, the report that was targetted by the original action triggering this flow).
This allows a more intelligent reloading of the previous options during user navigation (especially concerning sections and variants;
you expect your report to open by default the same section as last time you opened it in this http session).
*/
return `account.report:${ this.actionReportId }:${ user.defaultCompany.id }`;
}
hasSessionOptions() {
return Boolean(browser.sessionStorage.getItem(this.sessionOptionsID()))
}
saveSessionOptions(options) {
browser.sessionStorage.setItem(this.sessionOptionsID(), JSON.stringify(options));
}
sessionOptions() {
return JSON.parse(browser.sessionStorage.getItem(this.sessionOptionsID()));
}
//------------------------------------------------------------------------------------------------------------------
// Lines
//------------------------------------------------------------------------------------------------------------------
lineHasDebugData(lineIndex) {
return 'debug_popup_data' in this.lines[lineIndex];
}
lineHasGrowthComparisonData(lineIndex) {
return Boolean(this.lines[lineIndex].column_percent_comparison_data);
}
isLineAncestorOf(ancestorLineId, lineId) {
return lineId.startsWith(`${ancestorLineId}|`);
}
isLineChildOf(childLineId, lineId) {
return childLineId.startsWith(`${lineId}|`);
}
isLineRelatedTo(relatedLineId, lineId) {
return this.isLineAncestorOf(relatedLineId, lineId) || this.isLineChildOf(relatedLineId, lineId);
}
isNextLineChild(index, lineId) {
return index < this.lines.length && this.lines[index].id.startsWith(lineId);
}
isNextLineDirectChild(index, lineId) {
return index < this.lines.length && this.lines[index].parent_id === lineId;
}
isTotalLine(lineIndex) {
return this.lines[lineIndex].id.includes("|total~~");
}
isLoadMoreLine(lineIndex) {
return this.lines[lineIndex].id.includes("|load_more~~");
}
isLoadedLine(lineIndex) {
const lineID = this.lines[lineIndex].id;
const nextLineIndex = lineIndex + 1;
return this.isNextLineChild(nextLineIndex, lineID) && !this.isTotalLine(nextLineIndex) && !this.isLoadMoreLine(nextLineIndex);
}
async replaceLineWith(replaceIndex, newLines) {
await this.insertLines(replaceIndex, 1, newLines);
}
async insertLinesAfter(insertIndex, newLines) {
await this.insertLines(insertIndex + 1, 0, newLines);
}
async insertLines(lineIndex, deleteCount, newLines) {
this.lines.splice(lineIndex, deleteCount, ...newLines);
}
//------------------------------------------------------------------------------------------------------------------
// Unfolded/Folded lines
//------------------------------------------------------------------------------------------------------------------
async unfoldLoadedLine(lineIndex) {
const lineId = this.lines[lineIndex].id;
let nextLineIndex = lineIndex + 1;
while (this.isNextLineChild(nextLineIndex, lineId)) {
if (this.isNextLineDirectChild(nextLineIndex, lineId)) {
const nextLine = this.lines[nextLineIndex];
nextLine.visible = true;
if (!nextLine.unfoldable && this.isNextLineChild(nextLineIndex + 1, nextLine.id)) {
await this.unfoldLine(nextLineIndex);
}
}
nextLineIndex += 1;
}
return nextLineIndex;
}
async unfoldNewLine(lineIndex) {
const options = await this.options;
const newLines = await this.orm.call(
"account.report",
options.readonly_query ? "get_expanded_lines_readonly" : "get_expanded_lines",
[
this.options['report_id'],
this.options,
this.lines[lineIndex].id,
this.lines[lineIndex].groupby,
this.lines[lineIndex].expand_function,
this.lines[lineIndex].progress,
0,
this.lines[lineIndex].horizontal_split_side,
],
);
if (this.areLinesOrdered()) {
this.updateLinesOrderIndexes(lineIndex, newLines, false)
}
this.insertLinesAfter(lineIndex, newLines);
const totalIndex = lineIndex + newLines.length + 1;
if (this.filters.show_totals && this.lines[totalIndex] && this.isTotalLine(totalIndex))
this.lines[totalIndex].visible = true;
return totalIndex
}
/**
* When unfolding a line of a sorted report, we need to update the linesOrder array by adding the new lines,
* which will require subsequent updates on the array.
*
* - lineOrderValue represents the line index before sorting the report.
* @param {Integer} lineIndex: Index of the current line
* @param {Array} newLines: Array of lines to be added
* @param {Boolean} replaceLine: Useful for the splice of the linesOrder array in case we want to replace some line
* example: With the load more, we want to replace the line with others
**/
updateLinesOrderIndexes(lineIndex, newLines, replaceLine) {
let unfoldedLineIndex;
// The offset is useful because in case we use 'replaceLineWith' we want to replace the line at index
// unfoldedLineIndex with the new lines.
const offset = replaceLine ? 0 : 1;
for (const [lineOrderIndex, lineOrderValue] of Object.entries(this.linesOrder)) {
// Since we will have to add new lines into the linesOrder array, we have to update the index of the lines
// having a bigger index than the one we will unfold.
// deleteCount of 1 means that a line need to be replaced so the index need to be increase by 1 less than usual
if (lineOrderValue > lineIndex) {
this.linesOrder[lineOrderIndex] += newLines.length - replaceLine;
}
// The unfolded line is found, providing a reference for adding children in the 'linesOrder' array.
if (lineOrderValue === lineIndex) {
unfoldedLineIndex = parseInt(lineOrderIndex)
}
}
const arrayOfNewIndex = Array.from({ length: newLines.length }, (dummy, index) => this.linesOrder[unfoldedLineIndex] + index + offset);
this.linesOrder.splice(unfoldedLineIndex + offset, replaceLine, ...arrayOfNewIndex);
}
async unfoldLine(lineIndex) {
const targetLine = this.lines[lineIndex];
let lastLineIndex = lineIndex + 1;
if (this.isLoadedLine(lineIndex))
lastLineIndex = await this.unfoldLoadedLine(lineIndex);
else if (targetLine.expand_function) {
lastLineIndex = await this.unfoldNewLine(lineIndex);
}
this.setLineVisibility(this.lines.slice(lineIndex + 1, lastLineIndex));
targetLine.unfolded = true;
this.refreshVisibleAnnotations();
// Update options
if (!this.options.unfolded_lines.includes(targetLine.id))
this.options.unfolded_lines.push(targetLine.id);
this.saveSessionOptions(this.options);
}
foldLine(lineIndex) {
const targetLine = this.lines[lineIndex];
let foldedLinesIDs = new Set([targetLine.id]);
let nextLineIndex = lineIndex + 1;
while (this.isNextLineChild(nextLineIndex, targetLine.id)) {
this.lines[nextLineIndex].unfolded = false;
this.lines[nextLineIndex].visible = false;
foldedLinesIDs.add(this.lines[nextLineIndex].id);
nextLineIndex += 1;
}
targetLine.unfolded = false;
this.refreshVisibleAnnotations();
// Update options
this.options.unfolded_lines = this.options.unfolded_lines.filter(
unfoldedLineID => !foldedLinesIDs.has(unfoldedLineID)
);
this.saveSessionOptions(this.options);
}
//------------------------------------------------------------------------------------------------------------------
// Ordered lines
//------------------------------------------------------------------------------------------------------------------
linesCurrentOrderByColumn(columnIndex) {
if (this.areLinesOrderedByColumn(columnIndex))
return this.options.order_column.direction;
return "default";
}
areLinesOrdered() {
return this.linesOrder != null && this.options.order_column != null;
}
areLinesOrderedByColumn(columnIndex) {
return this.areLinesOrdered() && this.options.order_column.expression_label === this.options.columns[columnIndex].expression_label;
}
async sortLinesByColumnAsc(columnIndex) {
this.options.order_column = {
expression_label: this.options.columns[columnIndex].expression_label,
direction: "ASC",
};
await this.sortLines();
this.saveSessionOptions(this.options);
}
async sortLinesByColumnDesc(columnIndex) {
this.options.order_column = {
expression_label: this.options.columns[columnIndex].expression_label,
direction: "DESC",
};
await this.sortLines();
this.saveSessionOptions(this.options);
}
sortLinesByDefault() {
delete this.options.order_column;
delete this.data.lines_order;
this.saveSessionOptions(this.options);
}
async sortLines() {
this.linesOrder = await this.orm.call(
"account.report",
"sort_lines",
[
this.lines,
this.options,
true,
],
{
context: this.action.context,
},
);
}
//------------------------------------------------------------------------------------------------------------------
// Annotations
//------------------------------------------------------------------------------------------------------------------
async refreshAnnotations() {
this.annotations = await this.orm.call("account.report", "get_annotations", [
this.action.context.report_id,
this.options,
]);
this.refreshVisibleAnnotations();
}
//------------------------------------------------------------------------------------------------------------------
// Visibility
//------------------------------------------------------------------------------------------------------------------
refreshVisibleAnnotations() {
const visibleAnnotations = new Proxy(
{},
{
get(target, name) {
return name in target ? target[name] : [];
},
set(target, name, newValue) {
target[name] = newValue;
return true;
},
}
);
this.lines.forEach((line) => {
line["visible_annotations"] = [];
const lineWithoutTaxGrouping = removeTaxGroupingFromLineId(line.id);
if (line.visible && this.annotations[lineWithoutTaxGrouping]) {
for (const index in this.annotations[lineWithoutTaxGrouping]) {
const annotation = this.annotations[lineWithoutTaxGrouping][index];
visibleAnnotations[lineWithoutTaxGrouping] = [
...visibleAnnotations[lineWithoutTaxGrouping],
{ ...annotation },
];
line["visible_annotations"].push({
...annotation,
});
}
}
if (
line.visible_annotations &&
(!this.annotations[lineWithoutTaxGrouping] || !line.visible)
) {
delete line.visible_annotations;
}
});
this.visibleAnnotations = visibleAnnotations;
}
/**
Defines which lines should be visible in the provided list of lines (depending on what is folded).
**/
setLineVisibility(linesToAssign) {
let needHidingChildren = new Set();
linesToAssign.forEach((line) => {
line.visible = !needHidingChildren.has(line.parent_id);
if (!line.visible || (line.unfoldable &! line.unfolded))
needHidingChildren.add(line.id);
});
// If the hide 0 lines is activated we will go through the lines to set the visibility.
if (this.options.hide_0_lines) {
this.hideZeroLines(linesToAssign);
}
}
/**
* Defines whether the line should be visible depending on its value and the ones of its children.
* For parent lines, it's visible if there is at least one child with a value different from zero
* or if a child is visible, indicating it's a parent line.
* For leaf nodes, it's visible if the value is different from zero.
*
* By traversing the 'lines' array in reverse, we can set the visibility of the lines easily by keeping
* a dict of visible lines for each parent.
*
* @param {Object} lines - The lines for which we want to determine visibility.
*/
hideZeroLines(lines) {
const hasVisibleChildren = new Set();
const reversed_lines = [...lines].reverse()
const number_figure_types = ['integer', 'float', 'monetary', 'percentage'];
reversed_lines.forEach((line) => {
const isZero = line.columns.every(column => !number_figure_types.includes(column.figure_type) || column.is_zero);
// If the line has no visible children and all the columns are equals to zero then the line needs to be hidden
if (!hasVisibleChildren.has(line.id) && isZero) {
line.visible = false;
}
// If the line has a parent_id and is not hidden then we fill the set 'hasVisibleChildren'. Each parent
// will have an array of his visible children
if (line.parent_id && line.visible) {
// This line allows the initialization of that list.
hasVisibleChildren.add(line.parent_id);
}
})
}
//------------------------------------------------------------------------------------------------------------------
// Server calls
//------------------------------------------------------------------------------------------------------------------
async reportAction(ev, action, actionParam = null, callOnSectionsSource = false) {
// 'ev' might be 'undefined' if event is not triggered from a button/anchor
ev?.preventDefault();
ev?.stopPropagation();
let actionOptions = this.options;
if (callOnSectionsSource) {
// When calling the sections source, we want to keep track of all unfolded lines of all sections
const allUnfoldedLines = this.options.sections.length ? [] : [...this.options['unfolded_lines']]
for (const sectionData of this.options['sections']) {
const cacheKey = this.getCacheKey(this.options['sections_source_id'], sectionData['id']);
const sectionOptions = await this.reportOptionsMap[cacheKey];
if (sectionOptions)
allUnfoldedLines.push(...sectionOptions['unfolded_lines']);
}
actionOptions = {...this.options, unfolded_lines: allUnfoldedLines};
}
const dispatchReportAction = await this.orm.call(
"account.report",
"dispatch_report_action",
[
this.options['report_id'],
actionOptions,
action,
actionParam,
callOnSectionsSource,
],
);
if (dispatchReportAction?.help) {
dispatchReportAction.help = owl.markup(dispatchReportAction.help)
}
return dispatchReportAction ? this.actionService.doAction(dispatchReportAction) : null;
}
// -----------------------------------------------------------------------------------------------------------------
// Budget
// -----------------------------------------------------------------------------------------------------------------
async openBudget(budget) {
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "account.report.budget",
res_id: budget.id,
views: [[false, "form"]],
});
}
}