7262 lines
369 KiB
Python
7262 lines
369 KiB
Python
# Fusion Accounting - Financial Report Engine
|
|
# Copyright (C) 2026 Nexa Systems Inc. (https://nexasystems.ca)
|
|
# Original implementation for the Fusion Accounting module.
|
|
|
|
import ast
|
|
import base64
|
|
import datetime
|
|
import io
|
|
import json
|
|
import logging
|
|
import re
|
|
from ast import literal_eval
|
|
from collections import defaultdict
|
|
from functools import cmp_to_key
|
|
from itertools import groupby
|
|
|
|
import markupsafe
|
|
from dateutil.relativedelta import relativedelta
|
|
from PIL import ImageFont
|
|
|
|
from odoo import models, fields, api, _, osv
|
|
from odoo.addons.web.controllers.utils import clean_action
|
|
from odoo.exceptions import RedirectWarning, UserError, ValidationError
|
|
from odoo.models import check_method_name
|
|
from odoo.tools import date_utils, get_lang, float_is_zero, float_repr, SQL, parse_version, Query
|
|
from odoo.tools.float_utils import float_round, float_compare
|
|
from odoo.tools.misc import file_path, format_date, formatLang, split_every
|
|
|
|
try:
|
|
from odoo.tools.misc import xlsxwriter
|
|
except ImportError:
|
|
import xlsxwriter
|
|
from odoo.tools.safe_eval import expr_eval, safe_eval
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
ACCOUNT_CODES_ENGINE_SPLIT_REGEX = re.compile(r"(?=[+-])")
|
|
|
|
ACCOUNT_CODES_ENGINE_TERM_REGEX = re.compile(
|
|
r"^(?P<sign>[+-]?)"\
|
|
r"(?P<prefix>([A-Za-z\d.]*|tag\([\w.]+\))((?=\\)|(?<=[^CD])))"\
|
|
r"(\\\((?P<excluded_prefixes>([A-Za-z\d.]+,)*[A-Za-z\d.]*)\))?"\
|
|
r"(?P<balance_character>[DC]?)$"
|
|
)
|
|
|
|
ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX = re.compile(r"tag\(((?P<id>\d+)|(?P<ref>\w+\.\w+))\)")
|
|
|
|
# Performance optimisation: those engines always will receive None as their next_groupby, allowing more efficient batching.
|
|
NO_NEXT_GROUPBY_ENGINES = {'tax_tags', 'account_codes'}
|
|
|
|
NUMBER_FIGURE_TYPES = ('float', 'integer', 'monetary', 'percentage')
|
|
|
|
LINE_ID_HIERARCHY_DELIMITER = '|'
|
|
|
|
CURRENCIES_USING_LAKH = {'AFN', 'BDT', 'INR', 'MMK', 'NPR', 'PKR', 'LKR'}
|
|
|
|
|
|
class AccountReportAnnotation(models.Model):
|
|
_name = 'account.report.annotation'
|
|
_description = 'Account Report Annotation'
|
|
|
|
report_id = fields.Many2one('account.report', help="The id of the annotated report.")
|
|
line_id = fields.Char(index=True, help="The id of the annotated line.")
|
|
text = fields.Char(string="The annotation's content.")
|
|
date = fields.Date(help="Date considered as annotated by the annotation.")
|
|
fiscal_position_id = fields.Many2one('account.fiscal.position', help="The fiscal position used while annotating.")
|
|
|
|
@api.model_create_multi
|
|
def create(self, values):
|
|
fiscal_positions_with_foreign_vat = self.env['account.fiscal.position'].search([('foreign_vat', '!=', False)], limit=1)
|
|
for annotation in values:
|
|
if 'line_id' in annotation:
|
|
annotation['line_id'] = self._remove_tax_grouping_from_line_id(annotation['line_id'])
|
|
if 'fiscal_position_id' in annotation:
|
|
if annotation['fiscal_position_id'] == 'domestic':
|
|
del annotation['fiscal_position_id']
|
|
elif annotation['fiscal_position_id'] == 'all':
|
|
annotation['fiscal_position_id'] = fiscal_positions_with_foreign_vat.id
|
|
else:
|
|
annotation['fiscal_position_id'] = int(annotation['fiscal_position_id'])
|
|
|
|
return super().create(values)
|
|
|
|
def _remove_tax_grouping_from_line_id(self, line_id):
|
|
"""
|
|
Remove the tax grouping from the line_id. This is needed because the tax grouping is not relevant for the annotation.
|
|
Tax grouping are any group using 'account.group' in the line_id.
|
|
"""
|
|
return self.env['account.report']._build_line_id([
|
|
(markup, model, res_id)
|
|
for markup, model, res_id in self.env['account.report']._parse_line_id(line_id, markup_as_string=True)
|
|
if model != 'account.group'
|
|
])
|
|
|
|
class AccountReport(models.Model):
|
|
_inherit = 'account.report'
|
|
|
|
horizontal_group_ids = fields.Many2many(string="Horizontal Groups", comodel_name='account.report.horizontal.group')
|
|
annotations_ids = fields.One2many(string="Annotations", comodel_name='account.report.annotation', inverse_name='report_id')
|
|
|
|
# Those fields allow case-by-case fine-tuning of the engine, for custom reports.
|
|
custom_handler_model_id = fields.Many2one(string='Custom Handler Model', comodel_name='ir.model')
|
|
custom_handler_model_name = fields.Char(string='Custom Handler Model Name', related='custom_handler_model_id.model')
|
|
|
|
# Account Coverage Report
|
|
is_account_coverage_report_available = fields.Boolean(compute='_compute_is_account_coverage_report_available')
|
|
|
|
tax_closing_start_date = fields.Date( # the default value is set in _auto_init
|
|
string="Start Date",
|
|
company_dependent=True
|
|
)
|
|
|
|
# Fields used for send reports by cron
|
|
send_and_print_values = fields.Json(copy=False)
|
|
|
|
def _auto_init(self):
|
|
super()._auto_init()
|
|
|
|
def precommit():
|
|
self.env['ir.default'].set(
|
|
'account.report',
|
|
'tax_closing_start_date',
|
|
fields.Date.context_today(self).replace(month=1, day=1),
|
|
)
|
|
self.env.cr.precommit.add(precommit)
|
|
|
|
@api.constrains('custom_handler_model_id')
|
|
def _validate_custom_handler_model(self):
|
|
for report in self:
|
|
if report.custom_handler_model_id:
|
|
custom_handler_model = self.env.registry['account.report.custom.handler']
|
|
current_model = self.env[report.custom_handler_model_name]
|
|
if not isinstance(current_model, custom_handler_model):
|
|
raise ValidationError(_(
|
|
"Field 'Custom Handler Model' can only reference records inheriting from [%s].",
|
|
custom_handler_model._name
|
|
))
|
|
|
|
def unlink(self):
|
|
for report in self:
|
|
action, menuitem = report._get_existing_menuitem()
|
|
menuitem.unlink()
|
|
action.unlink()
|
|
return super().unlink()
|
|
|
|
def write(self, vals):
|
|
if 'active' in vals:
|
|
for report in self:
|
|
dummy, menuitem = report._get_existing_menuitem()
|
|
menuitem.active = vals['active']
|
|
return super().write(vals)
|
|
|
|
####################################################
|
|
# CRON
|
|
####################################################
|
|
|
|
@api.model
|
|
def _cron_account_report_send(self, job_count=10):
|
|
""" Handle Send & Print async processing.
|
|
:param job_count: maximum number of jobs to process if specified.
|
|
"""
|
|
to_process = self.env['account.report'].search(
|
|
[('send_and_print_values', '!=', False)],
|
|
)
|
|
if not to_process:
|
|
return
|
|
|
|
processed_count = 0
|
|
need_retrigger = False
|
|
|
|
for report in to_process:
|
|
if need_retrigger:
|
|
break
|
|
send_and_print_vals = report.send_and_print_values
|
|
report_partner_ids = send_and_print_vals.get('report_options', {}).get('partner_ids', [])
|
|
need_retrigger = processed_count + len(report_partner_ids) > job_count
|
|
for _id in report_partner_ids[:job_count - processed_count]:
|
|
options = {
|
|
**send_and_print_vals['report_options'],
|
|
'partner_ids': [_id],
|
|
}
|
|
self.env['account.report.send']._process_send_and_print(report=report, options=options)
|
|
processed_count += 1
|
|
report_partner_ids.remove(_id)
|
|
if report_partner_ids:
|
|
send_and_print_vals['report_options']['partner_ids'] = report_partner_ids
|
|
report.send_and_print_values = send_and_print_vals
|
|
else:
|
|
report.send_and_print_values = False
|
|
|
|
if need_retrigger:
|
|
self.env.ref('fusion_accounting.ir_cron_account_report_send')._trigger()
|
|
|
|
####################################################
|
|
# MENU MANAGEMENT
|
|
####################################################
|
|
|
|
def _get_existing_menuitem(self):
|
|
self.ensure_one()
|
|
action = self.env['ir.actions.client']\
|
|
.search([('name', '=', self.name), ('tag', '=', 'account_report')])\
|
|
.filtered(lambda act: ast.literal_eval(act.context).get('report_id') == self.id)
|
|
menuitem = self.env['ir.ui.menu']\
|
|
.with_context({'active_test': False, 'ir.ui.menu.full_list': True})\
|
|
.search([('action', '=', f'ir.actions.client,{action.id}')])
|
|
return action, menuitem
|
|
|
|
def _create_menu_item_for_report(self):
|
|
""" Adds a default menu item for this report. This is called by an action on the report, for reports created manually by the user.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
action, menuitem = self._get_existing_menuitem()
|
|
|
|
if menuitem:
|
|
raise UserError(_("This report already has a menuitem."))
|
|
|
|
if not action:
|
|
action = self.env['ir.actions.client'].create({
|
|
'name': self.name,
|
|
'tag': 'account_report',
|
|
'context': {'report_id': self.id},
|
|
})
|
|
|
|
self.env['ir.ui.menu'].create({
|
|
'name': self.name,
|
|
'parent_id': self.env['ir.model.data']._xmlid_to_res_id('account.menu_finance_reports'),
|
|
'action': f'ir.actions.client,{action.id}',
|
|
})
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'reload',
|
|
}
|
|
|
|
####################################################
|
|
# OPTIONS: journals
|
|
####################################################
|
|
|
|
def _get_filter_journals(self, options, additional_domain=None):
|
|
return self.env['account.journal'].with_context(active_test=False).search([
|
|
*self.env['account.journal']._check_company_domain(self.get_report_company_ids(options)),
|
|
*(additional_domain or []),
|
|
], order="company_id, name")
|
|
|
|
def _get_filter_journal_groups(self, options):
|
|
return self.env['account.journal.group'].search([
|
|
*self.env['account.journal.group']._check_company_domain(self.get_report_company_ids(options)),
|
|
], order='sequence')
|
|
|
|
def _init_options_journals(self, options, previous_options, additional_journals_domain=None):
|
|
# The additional additional_journals_domain optional parameter allows calling this with an additional restriction on journals,
|
|
# to regenerate the journal options accordingly.
|
|
def option_value(value, selected=False, group_journals=None):
|
|
result = {
|
|
'id': value.id,
|
|
'model': value._name,
|
|
'name': value.display_name,
|
|
'selected': selected,
|
|
}
|
|
|
|
if value._name == 'account.journal.group':
|
|
result.update({
|
|
'title': value.display_name,
|
|
'journals': group_journals.ids,
|
|
'journal_types': list(set(group_journals.mapped('type'))),
|
|
})
|
|
elif value._name == 'account.journal':
|
|
result.update({
|
|
'title': f"{value.name} - {value.code}",
|
|
'type': value.type,
|
|
'visible': True,
|
|
})
|
|
|
|
return result
|
|
|
|
if not self.filter_journals:
|
|
return
|
|
|
|
previous_journals = previous_options.get('journals', [])
|
|
previous_journal_group_action = previous_options.get('__journal_group_action', {})
|
|
|
|
all_journals = self._get_filter_journals(options, additional_domain=additional_journals_domain)
|
|
all_journal_groups = self._get_filter_journal_groups(options)
|
|
|
|
options['journals'] = []
|
|
options['selected_journal_groups'] = {}
|
|
|
|
groups_journals_selected = set()
|
|
options_journal_groups = []
|
|
|
|
# First time opening the report, and make sure it's not specifically stated that we should not reset the filter
|
|
is_opening_report = previous_options.get('is_opening_report') # key from JS controller when report is being opened
|
|
# a key to prevent the reset of the journals filter even when is_opening_report is True
|
|
can_reset_journals_filter = not previous_options.get('not_reset_journals_filter')
|
|
|
|
# 1. Handle journal group selection
|
|
for group in all_journal_groups:
|
|
group_journals = all_journals - group.excluded_journal_ids
|
|
selected = False
|
|
first_group_already_selected = bool(options['selected_journal_groups']) # only one group should be selected at most
|
|
|
|
# select the first group by default when opening the report
|
|
if is_opening_report and not first_group_already_selected and can_reset_journals_filter:
|
|
selected = True
|
|
# Otherwise, select the previous selected group (if any)
|
|
elif group.id == previous_journal_group_action.get('id'):
|
|
selected = previous_journal_group_action.get('action') == 'add'
|
|
|
|
group_option = option_value(group, selected=selected, group_journals=group_journals)
|
|
options_journal_groups.append(group_option)
|
|
|
|
# Select all the group journals
|
|
if selected:
|
|
options['selected_journal_groups'] = group_option
|
|
groups_journals_selected |= set(group_journals.ids)
|
|
|
|
# 2. Handle journals selection
|
|
previous_selected_journals_ids = {
|
|
journal['id']
|
|
for journal in previous_journals
|
|
if journal.get('model') == 'account.journal' and journal.get('selected')
|
|
}
|
|
|
|
company_journals_map = defaultdict(list)
|
|
journals_selected = set()
|
|
|
|
for journal in all_journals:
|
|
selected = False
|
|
|
|
if journal.id in groups_journals_selected:
|
|
selected = True
|
|
|
|
elif not options['selected_journal_groups'] and previous_journal_group_action.get('action') != 'remove':
|
|
if journal.id in previous_selected_journals_ids:
|
|
selected = True
|
|
|
|
if selected:
|
|
journals_selected.add(journal.id)
|
|
|
|
company_journals_map[journal.company_id].append(option_value(journal, selected=journal.id in journals_selected))
|
|
|
|
# 3. Recompute selected groups in case the set of selected journals is equal to a group's accepted journals
|
|
for group in options_journal_groups:
|
|
if journals_selected == set(group['journals']):
|
|
group['selected'] = True
|
|
options['selected_journal_groups'] = group
|
|
|
|
# 4. Unselect all journals if all are selected and no group is specifically selected
|
|
if journals_selected == set(all_journals.ids) and not options['selected_journal_groups']:
|
|
for company, journals in company_journals_map.items():
|
|
for journal in journals:
|
|
journal['selected'] = False
|
|
|
|
# 5. Build group options
|
|
if all_journal_groups:
|
|
options['journals'] = [{
|
|
'id': 'divider',
|
|
'name': _("Multi-ledger"),
|
|
'model': 'account.journal.group',
|
|
}] + options_journal_groups
|
|
|
|
if not company_journals_map:
|
|
options['name_journal_group'] = _("No Journal")
|
|
return
|
|
|
|
# 6. Build journals options
|
|
if len(company_journals_map) > 1 or all_journal_groups:
|
|
for company, journals in company_journals_map.items():
|
|
|
|
# if not is_opening_report, then gets the unfolded attribute of the company from the previous options
|
|
unfolded = False if is_opening_report else next(
|
|
(entry.get('unfolded') for entry in previous_journals
|
|
if entry['model'] == 'res.company' and entry['name'] == company.name), False)
|
|
|
|
for journal in journals:
|
|
journal['visible'] = unfolded
|
|
|
|
options['journals'].append({
|
|
'id': 'divider',
|
|
'model': company._name,
|
|
'name': company.display_name,
|
|
'unfolded': unfolded,
|
|
})
|
|
|
|
options['journals'] += journals
|
|
|
|
else:
|
|
options['journals'].extend(next(iter(company_journals_map.values()), []))
|
|
|
|
# 7 Compute the name to display on the widget
|
|
if options.get('selected_journal_groups'):
|
|
names_to_display = [options['selected_journal_groups']['name']]
|
|
elif len(all_journals) == len(journals_selected) or not journals_selected:
|
|
names_to_display = [_("All Journals")]
|
|
else:
|
|
names_to_display = []
|
|
|
|
for journal in options['journals']:
|
|
if journal.get('model') == 'account.journal' and journal['selected']:
|
|
names_to_display += [journal['name']]
|
|
|
|
# 8. Abbreviate the name
|
|
max_nb_journals_displayed = 5
|
|
nb_remaining = len(names_to_display) - max_nb_journals_displayed
|
|
displayed_names = ', '.join(names_to_display[:max_nb_journals_displayed])
|
|
if nb_remaining == 1:
|
|
options['name_journal_group'] = _("%(names)s and one other", names=displayed_names)
|
|
elif nb_remaining > 1:
|
|
options['name_journal_group'] = _("%(names)s and %(remaining)s others", names=displayed_names, remaining=nb_remaining)
|
|
else:
|
|
options['name_journal_group'] = displayed_names
|
|
|
|
@api.model
|
|
def _get_options_journals(self, options):
|
|
selected_journals = [
|
|
journal for journal in options.get('journals', [])
|
|
if journal['model'] == 'account.journal' and journal['selected']
|
|
]
|
|
if not selected_journals:
|
|
# If no journal is specifically selected, we actually want to select them all.
|
|
# This is needed, because some reports will not use ALL available journals and filter by type.
|
|
# Without getting them from the options, we will use them all, which is wrong.
|
|
selected_journals = [
|
|
journal for journal in options.get('journals', [])
|
|
if journal['model'] == 'account.journal'
|
|
]
|
|
return selected_journals
|
|
|
|
@api.model
|
|
def _get_options_journals_domain(self, options):
|
|
# Make sure to return an empty array when nothing selected to handle archived journals.
|
|
selected_journals = self._get_options_journals(options)
|
|
return selected_journals and [('journal_id', 'in', [j['id'] for j in selected_journals])] or []
|
|
|
|
# ####################################################
|
|
# OPTIONS: USER DEFINED FILTERS ON AML
|
|
####################################################
|
|
def _init_options_aml_ir_filters(self, options, previous_options):
|
|
options['aml_ir_filters'] = []
|
|
if not self.filter_aml_ir_filters:
|
|
return
|
|
|
|
ir_filters = self.env['ir.filters'].search([('model_id', '=', 'account.move.line')])
|
|
if not ir_filters:
|
|
return
|
|
|
|
aml_ir_filters = [{'id': x.id, 'name': x.name, 'selected': False} for x in ir_filters]
|
|
previous_options_aml_ir_filters = previous_options.get('aml_ir_filters', [])
|
|
previous_options_filters_map = {filter_item['id']: filter_item for filter_item in previous_options_aml_ir_filters}
|
|
|
|
for filter_item in aml_ir_filters:
|
|
if filter_item['id'] in previous_options_filters_map:
|
|
filter_item['selected'] = previous_options_filters_map[filter_item['id']]['selected']
|
|
|
|
options['aml_ir_filters'] = aml_ir_filters
|
|
|
|
@api.model
|
|
def _get_options_aml_ir_filters(self, options):
|
|
selected_filters_ids = [
|
|
filter_item['id']
|
|
for filter_item in options.get('aml_ir_filters', [])
|
|
if filter_item['selected']
|
|
]
|
|
|
|
if not selected_filters_ids:
|
|
return []
|
|
|
|
selected_ir_filters = self.env['ir.filters'].browse(selected_filters_ids)
|
|
return osv.expression.OR([filter_record._get_eval_domain() for filter_record in selected_ir_filters])
|
|
|
|
####################################################
|
|
# OPTIONS: date + comparison
|
|
####################################################
|
|
|
|
@api.model
|
|
def _get_dates_period(self, date_from, date_to, mode, period_type=None):
|
|
'''Compute some information about the period:
|
|
* The name to display on the report.
|
|
* The period type (e.g. quarter) if not specified explicitly.
|
|
:param date_from: The starting date of the period.
|
|
:param date_to: The ending date of the period.
|
|
:param period_type: The type of the interval date_from -> date_to.
|
|
:return: A dictionary containing:
|
|
* date_from * date_to * string * period_type * mode *
|
|
'''
|
|
def match(dt_from, dt_to):
|
|
return (dt_from, dt_to) == (date_from, date_to)
|
|
|
|
def get_quarter_name(date_to, date_from):
|
|
date_to_quarter_string = format_date(self.env, fields.Date.to_string(date_to), date_format='MMM yyyy')
|
|
date_from_quarter_string = format_date(self.env, fields.Date.to_string(date_from), date_format='MMM')
|
|
return f"{date_from_quarter_string} - {date_to_quarter_string}"
|
|
|
|
string = None
|
|
# If no date_from or not date_to, we are unable to determine a period
|
|
if not period_type or period_type == 'custom':
|
|
date = date_to or date_from
|
|
company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date)
|
|
if match(company_fiscalyear_dates['date_from'], company_fiscalyear_dates['date_to']):
|
|
period_type = 'fiscalyear'
|
|
if company_fiscalyear_dates.get('record'):
|
|
string = company_fiscalyear_dates['record'].name
|
|
elif match(*date_utils.get_month(date)):
|
|
period_type = 'month'
|
|
elif match(*date_utils.get_quarter(date)):
|
|
period_type = 'quarter'
|
|
elif match(*date_utils.get_fiscal_year(date)):
|
|
period_type = 'year'
|
|
elif match(date_utils.get_month(date)[0], fields.Date.today()):
|
|
period_type = 'today'
|
|
else:
|
|
period_type = 'custom'
|
|
elif period_type == 'fiscalyear':
|
|
date = date_to or date_from
|
|
company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date)
|
|
record = company_fiscalyear_dates.get('record')
|
|
string = record and record.name
|
|
elif period_type == 'tax_period':
|
|
day, month = self.env.company._get_tax_closing_start_date_attributes(self)
|
|
months_per_period = self.env.company._get_tax_periodicity_months_delay(self)
|
|
# We need to format ourselves the date and not switch the period type to the actual period because we do not want to write the actual period in the options but keep tax_period
|
|
if day == 1 and month == 1 and months_per_period in (1, 3, 12):
|
|
match months_per_period:
|
|
case 1:
|
|
string = format_date(self.env, fields.Date.to_string(date_to), date_format='MMM yyyy')
|
|
case 3:
|
|
string = get_quarter_name(date_to, date_from)
|
|
case 12:
|
|
string = date_to.strftime('%Y')
|
|
else:
|
|
dt_from_str = format_date(self.env, fields.Date.to_string(date_from))
|
|
dt_to_str = format_date(self.env, fields.Date.to_string(date_to))
|
|
string = '%s - %s' % (dt_from_str, dt_to_str)
|
|
|
|
if not string:
|
|
fy_day = self.env.company.fiscalyear_last_day
|
|
fy_month = int(self.env.company.fiscalyear_last_month)
|
|
if mode == 'single':
|
|
string = _('As of %s', format_date(self.env, date_to))
|
|
elif period_type == 'year' or (
|
|
period_type == 'fiscalyear' and (date_from, date_to) == date_utils.get_fiscal_year(date_to)):
|
|
string = date_to.strftime('%Y')
|
|
elif period_type == 'fiscalyear' and (date_from, date_to) == date_utils.get_fiscal_year(date_to, day=fy_day, month=fy_month):
|
|
string = '%s - %s' % (date_to.year - 1, date_to.year)
|
|
elif period_type == 'month':
|
|
string = format_date(self.env, fields.Date.to_string(date_to), date_format='MMM yyyy')
|
|
elif period_type == 'quarter':
|
|
string = get_quarter_name(date_to, date_from)
|
|
else:
|
|
dt_from_str = format_date(self.env, fields.Date.to_string(date_from))
|
|
dt_to_str = format_date(self.env, fields.Date.to_string(date_to))
|
|
string = _('From %(date_from)s\nto %(date_to)s', date_from=dt_from_str, date_to=dt_to_str)
|
|
|
|
return {
|
|
'string': string,
|
|
'period_type': period_type,
|
|
'currency_table_period_key': f"{date_from if mode == 'range' else 'None'}_{date_to}",
|
|
'mode': mode,
|
|
'date_from': date_from and fields.Date.to_string(date_from) or False,
|
|
'date_to': fields.Date.to_string(date_to),
|
|
}
|
|
|
|
@api.model
|
|
def _get_shifted_dates_period(self, options, period_vals, periods, tax_period=False):
|
|
'''Shift the period.
|
|
:param period_vals: A dictionary generated by the _get_dates_period method.
|
|
:param periods: The number of periods we want to move either in the future or the past
|
|
:return: A dictionary containing:
|
|
* date_from * date_to * string * period_type *
|
|
'''
|
|
period_type = period_vals['period_type']
|
|
mode = period_vals['mode']
|
|
date_from = fields.Date.from_string(period_vals['date_from'])
|
|
date_to = fields.Date.from_string(period_vals['date_to'])
|
|
if period_type == 'month':
|
|
date_to = date_from + relativedelta(months=periods)
|
|
elif period_type == 'quarter':
|
|
date_to = date_from + relativedelta(months=3 * periods)
|
|
elif period_type == 'year':
|
|
date_to = date_from + relativedelta(years=periods)
|
|
elif period_type in {'custom', 'today'}:
|
|
date_to = date_from + relativedelta(days=periods)
|
|
|
|
if tax_period or 'tax_period' in period_type:
|
|
month_per_period = self.env.company._get_tax_periodicity_months_delay(self)
|
|
date_from, date_to = self.env.company._get_tax_closing_period_boundaries(date_from + relativedelta(months=month_per_period * periods), self)
|
|
return self._get_dates_period(date_from, date_to, mode, period_type='tax_period')
|
|
if period_type in ('fiscalyear', 'today'):
|
|
# Don't pass the period_type to _get_dates_period to be able to retrieve the account.fiscal.year record if
|
|
# necessary.
|
|
company_fiscalyear_dates = {}
|
|
# This loop is needed because a fiscal year can be a month, quarter, etc
|
|
for _ in range(abs(periods)):
|
|
date_to = (date_from if periods < 0 else date_to) + relativedelta(days=periods)
|
|
company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date_to)
|
|
if periods < 0:
|
|
date_from = company_fiscalyear_dates['date_from']
|
|
else:
|
|
date_to = company_fiscalyear_dates['date_to']
|
|
|
|
return self._get_dates_period(company_fiscalyear_dates['date_from'], company_fiscalyear_dates['date_to'], mode)
|
|
if period_type in ('month', 'custom'):
|
|
return self._get_dates_period(*date_utils.get_month(date_to), mode, period_type='month')
|
|
if period_type == 'quarter':
|
|
return self._get_dates_period(*date_utils.get_quarter(date_to), mode, period_type='quarter')
|
|
if period_type == 'year':
|
|
return self._get_dates_period(*date_utils.get_fiscal_year(date_to), mode, period_type='year')
|
|
return None
|
|
|
|
@api.model
|
|
def _get_dates_previous_year(self, options, period_vals):
|
|
'''Shift the period to the previous year.
|
|
:param options: The report options.
|
|
:param period_vals: A dictionary generated by the _get_dates_period method.
|
|
:return: A dictionary containing:
|
|
* date_from * date_to * string * period_type *
|
|
'''
|
|
period_type = period_vals['period_type']
|
|
mode = period_vals['mode']
|
|
date_from = fields.Date.from_string(period_vals['date_from'])
|
|
date_from = date_from - relativedelta(years=1)
|
|
date_to = fields.Date.from_string(period_vals['date_to'])
|
|
date_to = date_to - relativedelta(years=1)
|
|
|
|
if period_type == 'month':
|
|
date_from, date_to = date_utils.get_month(date_to)
|
|
|
|
return self._get_dates_period(date_from, date_to, mode, period_type=period_type)
|
|
|
|
def _init_options_date(self, options, previous_options):
|
|
""" Initialize the 'date' options key.
|
|
|
|
:param options: The current report options to build.
|
|
:param previous_options: The previous options coming from another report.
|
|
"""
|
|
date = previous_options.get('date', {})
|
|
period_date_to = date.get('date_to')
|
|
period_date_from = date.get('date_from')
|
|
mode = date.get('mode')
|
|
date_filter = date.get('filter', 'custom')
|
|
|
|
default_filter = self.default_opening_date_filter
|
|
options_mode = 'range' if self.filter_date_range else 'single'
|
|
date_from = date_to = period_type = False
|
|
|
|
if mode == 'single' and options_mode == 'range':
|
|
# 'single' date mode to 'range'.
|
|
if date_filter:
|
|
date_to = fields.Date.from_string(period_date_to or period_date_from)
|
|
date_from = self.env.company.compute_fiscalyear_dates(date_to)['date_from']
|
|
options_filter = 'custom'
|
|
else:
|
|
options_filter = default_filter
|
|
elif mode == 'range' and options_mode == 'single':
|
|
# 'range' date mode to 'single'.
|
|
if date_filter == 'custom':
|
|
date_to = fields.Date.from_string(period_date_to or period_date_from)
|
|
date_from = date_utils.get_month(date_to)[0]
|
|
options_filter = 'custom'
|
|
elif date_filter:
|
|
options_filter = date_filter
|
|
else:
|
|
options_filter = default_filter
|
|
elif (mode is None or mode == options_mode) and date:
|
|
# Same date mode.
|
|
if date_filter == 'custom':
|
|
if options_mode == 'range':
|
|
date_from = fields.Date.from_string(period_date_from)
|
|
date_to = fields.Date.from_string(period_date_to)
|
|
else:
|
|
date_to = fields.Date.from_string(period_date_to or period_date_from)
|
|
date_from = date_utils.get_month(date_to)[0]
|
|
|
|
options_filter = 'custom'
|
|
else:
|
|
options_filter = date_filter
|
|
else:
|
|
# Default.
|
|
options_filter = default_filter
|
|
|
|
# Compute 'date_from' / 'date_to'.
|
|
if not date_from or not date_to:
|
|
if options_filter == 'today':
|
|
date_to = fields.Date.context_today(self)
|
|
date_from = self.env.company.compute_fiscalyear_dates(date_to)['date_from']
|
|
period_type = 'today'
|
|
elif 'month' in options_filter:
|
|
date_from, date_to = date_utils.get_month(fields.Date.context_today(self))
|
|
period_type = 'month'
|
|
elif 'quarter' in options_filter:
|
|
date_from, date_to = date_utils.get_quarter(fields.Date.context_today(self))
|
|
period_type = 'quarter'
|
|
elif 'year' in options_filter:
|
|
company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(fields.Date.context_today(self))
|
|
date_from = company_fiscalyear_dates['date_from']
|
|
date_to = company_fiscalyear_dates['date_to']
|
|
elif 'tax_period' in options_filter:
|
|
if 'custom' in options_filter:
|
|
base_date = fields.Date.from_string(period_date_to)
|
|
else:
|
|
base_date = fields.Date.context_today(self)
|
|
|
|
date_from, date_to = self.env.company._get_tax_closing_period_boundaries(base_date, self)
|
|
period_type = 'tax_period'
|
|
|
|
options['date'] = self._get_dates_period(
|
|
date_from,
|
|
date_to,
|
|
options_mode,
|
|
period_type=period_type,
|
|
)
|
|
|
|
if any(option in options_filter for option in ['previous', 'next']):
|
|
new_period = date.get('period', -1 if 'previous' in options_filter else 1)
|
|
options['date'] = self._get_shifted_dates_period(options, options['date'], new_period, tax_period='tax_period' in options_filter)
|
|
# This line is useful for the export and tax closing so that the period is set in the options.
|
|
options['date']['period'] = new_period
|
|
|
|
options['date']['filter'] = options_filter
|
|
|
|
def _init_options_comparison(self, options, previous_options):
|
|
""" Initialize the 'comparison' options key.
|
|
|
|
This filter must be loaded after the 'date' filter.
|
|
|
|
:param options: The current report options to build.
|
|
:param previous_options: The previous options coming from another report.
|
|
"""
|
|
if not self.filter_period_comparison:
|
|
return
|
|
|
|
previous_comparison = previous_options.get('comparison', {})
|
|
previous_filter = previous_comparison.get('filter')
|
|
|
|
period_order = previous_comparison.get('period_order') or 'descending'
|
|
if previous_filter == 'custom':
|
|
# Try to adapt the previous 'custom' filter.
|
|
date_from = previous_comparison.get('date_from')
|
|
date_to = previous_comparison.get('date_to')
|
|
number_period = 1
|
|
options_filter = 'custom'
|
|
else:
|
|
# Use the 'date' options.
|
|
date_from = options['date']['date_from']
|
|
date_to = options['date']['date_to']
|
|
number_period = max(previous_comparison.get('number_period', 1) or 0, 0)
|
|
options_filter = number_period and previous_filter or 'no_comparison'
|
|
|
|
options['comparison'] = {
|
|
'filter': options_filter,
|
|
'number_period': number_period,
|
|
'date_from': date_from,
|
|
'date_to': date_to,
|
|
'periods': [],
|
|
'period_order': period_order,
|
|
}
|
|
|
|
date_from_obj = fields.Date.from_string(date_from)
|
|
date_to_obj = fields.Date.from_string(date_to)
|
|
|
|
if options_filter == 'custom':
|
|
options['comparison']['periods'].append(self._get_dates_period(
|
|
date_from_obj,
|
|
date_to_obj,
|
|
options['date']['mode'],
|
|
))
|
|
elif options_filter in ('previous_period', 'same_last_year'):
|
|
previous_period = options['date']
|
|
for dummy in range(0, number_period):
|
|
if options_filter == 'previous_period':
|
|
period_vals = self._get_shifted_dates_period(options, previous_period, -1)
|
|
elif options_filter == 'same_last_year':
|
|
period_vals = self._get_dates_previous_year(options, previous_period)
|
|
else:
|
|
date_from_obj = fields.Date.from_string(date_from)
|
|
date_to_obj = fields.Date.from_string(date_to)
|
|
period_vals = self._get_dates_period(date_from_obj, date_to_obj, previous_period['mode'])
|
|
options['comparison']['periods'].append(period_vals)
|
|
previous_period = period_vals
|
|
|
|
if len(options['comparison']['periods']) > 0:
|
|
options['comparison'].update(options['comparison']['periods'][0])
|
|
|
|
def _init_options_column_percent_comparison(self, options, previous_options):
|
|
if options['selected_horizontal_group_id'] is None:
|
|
if self.filter_growth_comparison and len(options['columns']) == 2 and len(options.get('comparison', {}).get('periods', [])) == 1:
|
|
options['column_percent_comparison'] = 'growth'
|
|
|
|
if self.filter_budgets and any(budget['selected'] for budget in options.get('budgets', [])):
|
|
options['column_percent_comparison'] = 'budget'
|
|
|
|
def _get_options_date_domain(self, options, date_scope):
|
|
date_from, date_to = self._get_date_bounds_info(options, date_scope)
|
|
|
|
scope_domain = [('date', '<=', date_to)]
|
|
if date_from:
|
|
scope_domain += [('date', '>=', date_from)]
|
|
|
|
return scope_domain
|
|
|
|
def _get_date_bounds_info(self, options, date_scope):
|
|
# Default values (the ones from 'strict_range')
|
|
date_to = options['date']['date_to']
|
|
date_from = options['date']['date_from'] if options['date']['mode'] == 'range' else None
|
|
|
|
if date_scope == 'from_beginning':
|
|
date_from = None
|
|
|
|
elif date_scope == 'to_beginning_of_period':
|
|
date_tmp = fields.Date.from_string(date_from or date_to) - relativedelta(days=1)
|
|
date_to = date_tmp.strftime('%Y-%m-%d')
|
|
date_from = None
|
|
|
|
elif date_scope == 'from_fiscalyear':
|
|
date_tmp = fields.Date.from_string(date_to)
|
|
date_tmp = self.env.company.compute_fiscalyear_dates(date_tmp)['date_from']
|
|
date_from = date_tmp.strftime('%Y-%m-%d')
|
|
|
|
elif date_scope == 'to_beginning_of_fiscalyear':
|
|
date_tmp = fields.Date.from_string(date_to)
|
|
date_tmp = self.env.company.compute_fiscalyear_dates(date_tmp)['date_from'] - relativedelta(days=1)
|
|
date_to = date_tmp.strftime('%Y-%m-%d')
|
|
date_from = None
|
|
|
|
elif date_scope == 'previous_tax_period':
|
|
eve_of_date_from = fields.Date.from_string(options['date']['date_from']) - relativedelta(days=1)
|
|
date_from, date_to = self.env.company._get_tax_closing_period_boundaries(eve_of_date_from, self)
|
|
|
|
return date_from, date_to
|
|
|
|
|
|
####################################################
|
|
# OPTIONS: analytic filter
|
|
####################################################
|
|
|
|
def _init_options_analytic(self, options, previous_options):
|
|
if not self.filter_analytic:
|
|
return
|
|
|
|
|
|
if self.env.user.has_group('analytic.group_analytic_accounting'):
|
|
previous_analytic_accounts = previous_options.get('analytic_accounts', [])
|
|
analytic_account_ids = [int(x) for x in previous_analytic_accounts]
|
|
selected_analytic_accounts = self.env['account.analytic.account'].with_context(active_test=False).search([('id', 'in', analytic_account_ids)])
|
|
|
|
options['display_analytic'] = True
|
|
options['analytic_accounts'] = selected_analytic_accounts.ids
|
|
options['selected_analytic_account_names'] = selected_analytic_accounts.mapped('name')
|
|
|
|
####################################################
|
|
# OPTIONS: partners
|
|
####################################################
|
|
|
|
def _init_options_partner(self, options, previous_options):
|
|
if not self.filter_partner:
|
|
return
|
|
|
|
options['partner'] = True
|
|
previous_partner_ids = previous_options.get('partner_ids') or []
|
|
options['partner_categories'] = previous_options.get('partner_categories') or []
|
|
|
|
selected_partner_ids = [int(partner) for partner in previous_partner_ids]
|
|
# search instead of browse so that record rules apply and filter out the ones the user does not have access to
|
|
selected_partners = selected_partner_ids and self.env['res.partner'].with_context(active_test=False).search([('id', 'in', selected_partner_ids)]) or self.env['res.partner']
|
|
options['selected_partner_ids'] = selected_partners.mapped('name')
|
|
options['partner_ids'] = selected_partners.ids
|
|
|
|
selected_partner_category_ids = [int(category) for category in options['partner_categories']]
|
|
selected_partner_categories = selected_partner_category_ids and self.env['res.partner.category'].browse(selected_partner_category_ids) or self.env['res.partner.category']
|
|
options['selected_partner_categories'] = selected_partner_categories.mapped('name')
|
|
|
|
@api.model
|
|
def _get_options_partner_domain(self, options):
|
|
domain = []
|
|
if options.get('partner_ids'):
|
|
partner_ids = [int(partner) for partner in options['partner_ids']]
|
|
domain.append(('partner_id', 'in', partner_ids))
|
|
if options.get('partner_categories'):
|
|
partner_category_ids = [int(category) for category in options['partner_categories']]
|
|
domain.append(('partner_id.category_id', 'in', partner_category_ids))
|
|
return domain
|
|
|
|
####################################################
|
|
# OPTIONS: all_entries
|
|
####################################################
|
|
|
|
@api.model
|
|
def _get_options_all_entries_domain(self, options):
|
|
if not options.get('all_entries'):
|
|
return [('parent_state', '=', 'posted')]
|
|
else:
|
|
return [('parent_state', '!=', 'cancel')]
|
|
|
|
####################################################
|
|
# OPTIONS: not reconciled entries
|
|
####################################################
|
|
def _init_options_reconciled(self, options, previous_options):
|
|
if self.filter_unreconciled:
|
|
options['unreconciled'] = previous_options.get('unreconciled', False)
|
|
else:
|
|
options['unreconciled'] = False
|
|
|
|
@api.model
|
|
def _get_options_unreconciled_domain(self, options):
|
|
if options.get('unreconciled'):
|
|
return ['&', ('full_reconcile_id', '=', False), ('balance', '!=', '0')]
|
|
return []
|
|
|
|
####################################################
|
|
# OPTIONS: account_type
|
|
####################################################
|
|
|
|
def _init_options_account_type(self, options, previous_options):
|
|
'''
|
|
Initialize a filter based on the account_type of the line (trade/non trade, payable/receivable).
|
|
Selects a name to display according to the selections.
|
|
The group display name is selected according to the display name of the options selected.
|
|
'''
|
|
if self.filter_account_type in ('disabled', False):
|
|
return
|
|
|
|
account_type_list = [
|
|
{'id': 'trade_receivable', 'name': _("Receivable"), 'selected': True},
|
|
{'id': 'non_trade_receivable', 'name': _("Non Trade Receivable"), 'selected': False},
|
|
{'id': 'trade_payable', 'name': _("Payable"), 'selected': True},
|
|
{'id': 'non_trade_payable', 'name': _("Non Trade Payable"), 'selected': False},
|
|
]
|
|
|
|
if self.filter_account_type == 'receivable':
|
|
options['account_type'] = account_type_list[:2]
|
|
elif self.filter_account_type == 'payable':
|
|
options['account_type'] = account_type_list[2:]
|
|
else:
|
|
options['account_type'] = account_type_list
|
|
|
|
if previous_options.get('account_type'):
|
|
previously_selected_ids = {x['id'] for x in previous_options['account_type'] if x.get('selected')}
|
|
for opt in options['account_type']:
|
|
opt['selected'] = opt['id'] in previously_selected_ids
|
|
|
|
|
|
@api.model
|
|
def _get_options_account_type_domain(self, options):
|
|
all_domains = []
|
|
selected_domains = []
|
|
if not options.get('account_type') or len(options.get('account_type')) == 0:
|
|
return []
|
|
for opt in options.get('account_type', []):
|
|
if opt['id'] == 'trade_receivable':
|
|
domain = [('account_id.non_trade', '=', False), ('account_id.account_type', '=', 'asset_receivable')]
|
|
elif opt['id'] == 'trade_payable':
|
|
domain = [('account_id.non_trade', '=', False), ('account_id.account_type', '=', 'liability_payable')]
|
|
elif opt['id'] == 'non_trade_receivable':
|
|
domain = [('account_id.non_trade', '=', True), ('account_id.account_type', '=', 'asset_receivable')]
|
|
elif opt['id'] == 'non_trade_payable':
|
|
domain = [('account_id.non_trade', '=', True), ('account_id.account_type', '=', 'liability_payable')]
|
|
if opt['selected']:
|
|
selected_domains.append(domain)
|
|
all_domains.append(domain)
|
|
return osv.expression.OR(selected_domains or all_domains)
|
|
|
|
####################################################
|
|
# OPTIONS: order column
|
|
####################################################
|
|
|
|
@api.model
|
|
def _init_options_order_column(self, options, previous_options):
|
|
# options['order_column'] is in the form {'expression_label': expression label of the column to order, 'direction': the direction order ('ASC' or 'DESC')}
|
|
options['order_column'] = None
|
|
|
|
previous_value = previous_options and previous_options.get('order_column')
|
|
if previous_value:
|
|
for col in options['columns']:
|
|
if col['sortable'] and col['expression_label'] == previous_value['expression_label']:
|
|
options['order_column'] = previous_value
|
|
break
|
|
|
|
####################################################
|
|
# OPTIONS: hierarchy
|
|
####################################################
|
|
|
|
def _init_options_hierarchy(self, options, previous_options):
|
|
company_ids = self.get_report_company_ids(options)
|
|
if self.filter_hierarchy != 'never' and self.env['account.group'].search_count(self.env['account.group']._check_company_domain(company_ids), limit=1):
|
|
options['display_hierarchy_filter'] = True
|
|
if 'hierarchy' in previous_options:
|
|
options['hierarchy'] = previous_options['hierarchy']
|
|
else:
|
|
options['hierarchy'] = self.filter_hierarchy == 'by_default'
|
|
else:
|
|
options['hierarchy'] = False
|
|
options['display_hierarchy_filter'] = False
|
|
|
|
@api.model
|
|
def _create_hierarchy(self, lines, options):
|
|
"""Compute the hierarchy based on account groups when the option is activated.
|
|
|
|
The option is available only when there are account.group for the company.
|
|
It should be called when before returning the lines to the client/templater.
|
|
The lines are the result of _get_lines(). If there is a hierarchy, it is left
|
|
untouched, only the lines related to an account.account are put in a hierarchy
|
|
according to the account.group's and their prefixes.
|
|
"""
|
|
if not lines:
|
|
return lines
|
|
|
|
def get_account_group_hierarchy(account):
|
|
# Create codes path in the hierarchy based on account.
|
|
groups = self.env['account.group']
|
|
if account.group_id:
|
|
group = account.group_id
|
|
while group:
|
|
groups += group
|
|
group = group.parent_id
|
|
return list(groups.sorted(reverse=True))
|
|
|
|
def create_hierarchy_line(account_group, column_totals, level, parent_id):
|
|
line_id = self._get_generic_line_id('account.group', account_group.id if account_group else None, parent_line_id=parent_id)
|
|
unfolded = line_id in options.get('unfolded_lines') or options['unfold_all']
|
|
name = account_group.display_name if account_group else _('(No Group)')
|
|
columns = []
|
|
for column_total, column in zip(column_totals, options['columns']):
|
|
columns.append(self._build_column_dict(column_total, column, options=options))
|
|
return {
|
|
'id': line_id,
|
|
'name': name,
|
|
'title_hover': name,
|
|
'unfoldable': True,
|
|
'unfolded': unfolded,
|
|
'level': level,
|
|
'parent_id': parent_id,
|
|
'columns': columns,
|
|
}
|
|
|
|
def compute_group_totals(line, group=None):
|
|
return [
|
|
hierarchy_total + (column.get('no_format') or 0.0) if isinstance(hierarchy_total, float) else hierarchy_total
|
|
for hierarchy_total, column
|
|
in zip(hierarchy[group]['totals'], line['columns'])
|
|
]
|
|
|
|
def render_lines(account_groups, current_level, parent_line_id, skip_no_group=True):
|
|
to_treat = [(current_level, parent_line_id, group) for group in account_groups.sorted()]
|
|
|
|
if None in hierarchy and not skip_no_group:
|
|
to_treat.append((current_level, parent_line_id, None))
|
|
|
|
while to_treat:
|
|
level_to_apply, parent_id, group = to_treat.pop(0)
|
|
group_data = hierarchy[group]
|
|
hierarchy_line = create_hierarchy_line(group, group_data['totals'], level_to_apply, parent_id)
|
|
new_lines.append(hierarchy_line)
|
|
treated_child_groups = self.env['account.group']
|
|
|
|
for account_line in group_data['lines']:
|
|
for child_group in group_data['child_groups']:
|
|
if child_group not in treated_child_groups and child_group['code_prefix_end'] < account_line['name']:
|
|
render_lines(child_group, hierarchy_line['level'] + 1, hierarchy_line['id'])
|
|
treated_child_groups += child_group
|
|
|
|
markup, model, account_id = self._parse_line_id(account_line['id'])[-1]
|
|
account_line_id = self._get_generic_line_id(model, account_id, markup=markup, parent_line_id=hierarchy_line['id'])
|
|
account_line.update({
|
|
'id': account_line_id,
|
|
'parent_id': hierarchy_line['id'],
|
|
'level': hierarchy_line['level'] + 1,
|
|
})
|
|
new_lines.append(account_line)
|
|
|
|
for child_line in account_line_children_map[account_id]:
|
|
markup, model, res_id = self._parse_line_id(child_line['id'])[-1]
|
|
child_line.update({
|
|
'id': self._get_generic_line_id(model, res_id, markup=markup, parent_line_id=account_line_id),
|
|
'parent_id': account_line_id,
|
|
'level': account_line['level'] + 1,
|
|
})
|
|
new_lines.append(child_line)
|
|
|
|
to_treat = [
|
|
(level_to_apply + 1, hierarchy_line['id'], child_group)
|
|
for child_group
|
|
in group_data['child_groups'].sorted()
|
|
if child_group not in treated_child_groups
|
|
] + to_treat
|
|
|
|
def create_hierarchy_dict():
|
|
return defaultdict(lambda: {
|
|
'lines': [],
|
|
'totals': [('' if column.get('figure_type') == 'string' else 0.0) for column in options['columns']],
|
|
'child_groups': self.env['account.group'],
|
|
})
|
|
|
|
# Precompute the account groups of the accounts in the report
|
|
account_ids = []
|
|
for line in lines:
|
|
markup, res_model, model_id = self._parse_line_id(line['id'])[-1]
|
|
if res_model == 'account.account':
|
|
account_ids.append(model_id)
|
|
self.env['account.account'].browse(account_ids).group_id
|
|
|
|
new_lines, total_lines = [], []
|
|
|
|
# root_line_id is the id of the parent line of the lines we want to render
|
|
root_line_id = self._build_parent_line_id(self._parse_line_id(lines[0]['id'])) or None
|
|
last_account_line_id = account_id = None
|
|
current_level = 0
|
|
account_line_children_map = defaultdict(list)
|
|
account_groups = self.env['account.group']
|
|
root_account_groups = self.env['account.group']
|
|
hierarchy = create_hierarchy_dict()
|
|
|
|
for line in lines:
|
|
markup, res_model, model_id = self._parse_line_id(line['id'])[-1]
|
|
|
|
# Account lines are used as the basis for the computation of the hierarchy.
|
|
if res_model == 'account.account':
|
|
last_account_line_id = line['id']
|
|
current_level = line['level']
|
|
account_id = model_id
|
|
account = self.env[res_model].browse(account_id)
|
|
account_groups = get_account_group_hierarchy(account)
|
|
|
|
if not account_groups:
|
|
hierarchy[None]['lines'].append(line)
|
|
hierarchy[None]['totals'] = compute_group_totals(line)
|
|
else:
|
|
for i, group in enumerate(account_groups):
|
|
if i == 0:
|
|
hierarchy[group]['lines'].append(line)
|
|
if i == len(account_groups) - 1 and group not in root_account_groups:
|
|
root_account_groups += group
|
|
if group.parent_id and group not in hierarchy[group.parent_id]['child_groups']:
|
|
hierarchy[group.parent_id]['child_groups'] += group
|
|
|
|
hierarchy[group]['totals'] = compute_group_totals(line, group=group)
|
|
|
|
# This is not an account line, so we check to see if it is a descendant of the last account line.
|
|
# If so, it is added to the mapping of the lines that are related to this account.
|
|
elif last_account_line_id and line.get('parent_id', '').startswith(last_account_line_id):
|
|
account_line_children_map[account_id].append(line)
|
|
|
|
# This is a total line that is not linked to an account. It is saved in order to be added at the end.
|
|
elif markup == 'total':
|
|
total_lines.append(line)
|
|
|
|
# This line ends the scope of the current hierarchy and is (possibly) the root of a new hierarchy.
|
|
# We render the current hierarchy and set up to build a new hierarchy
|
|
else:
|
|
render_lines(root_account_groups, current_level, root_line_id, skip_no_group=False)
|
|
|
|
new_lines.append(line)
|
|
|
|
# Reset the hierarchy-related variables for a new hierarchy
|
|
root_line_id = line['id']
|
|
last_account_line_id = account_id = None
|
|
current_level = 0
|
|
account_line_children_map = defaultdict(list)
|
|
root_account_groups = self.env['account.group']
|
|
account_groups = self.env['account.group']
|
|
hierarchy = create_hierarchy_dict()
|
|
|
|
render_lines(root_account_groups, current_level, root_line_id, skip_no_group=False)
|
|
|
|
return new_lines + total_lines
|
|
|
|
####################################################
|
|
# OPTIONS: prefix groups threshold
|
|
####################################################
|
|
|
|
def _init_options_prefix_groups_threshold(self, options, previous_options):
|
|
previous_threshold = previous_options.get('prefix_groups_threshold')
|
|
options['prefix_groups_threshold'] = self.prefix_groups_threshold
|
|
|
|
####################################################
|
|
# OPTIONS: fiscal position (multi vat)
|
|
####################################################
|
|
|
|
def _init_options_fiscal_position(self, options, previous_options):
|
|
if self.filter_fiscal_position and self.country_id and len(options['companies']) == 1:
|
|
vat_fpos_domain = [
|
|
*self.env['account.fiscal.position']._check_company_domain(next(comp_id for comp_id in self.get_report_company_ids(options))),
|
|
('foreign_vat', '!=', False),
|
|
]
|
|
|
|
vat_fiscal_positions = self.env['account.fiscal.position'].search([
|
|
*vat_fpos_domain,
|
|
('country_id', '=', self.country_id.id),
|
|
])
|
|
|
|
options['allow_domestic'] = self.env.company.account_fiscal_country_id == self.country_id
|
|
|
|
accepted_prev_vals = {*vat_fiscal_positions.ids}
|
|
if options['allow_domestic']:
|
|
accepted_prev_vals.add('domestic')
|
|
if len(vat_fiscal_positions) > (0 if options['allow_domestic'] else 1) or not accepted_prev_vals:
|
|
accepted_prev_vals.add('all')
|
|
|
|
if previous_options.get('fiscal_position') in accepted_prev_vals:
|
|
# Legit value from previous options; keep it
|
|
options['fiscal_position'] = previous_options['fiscal_position']
|
|
elif len(vat_fiscal_positions) == 1 and not options['allow_domestic']:
|
|
# Only one foreign fiscal position: always select it, menu will be hidden
|
|
options['fiscal_position'] = vat_fiscal_positions.id
|
|
else:
|
|
# Multiple possible values; by default, show the values of the company's area (if allowed), or everything
|
|
options['fiscal_position'] = options['allow_domestic'] and 'domestic' or 'all'
|
|
else:
|
|
# No country, or we're displaying data from several companies: disable fiscal position filtering
|
|
vat_fiscal_positions = []
|
|
options['allow_domestic'] = True
|
|
previous_fpos = previous_options.get('fiscal_position')
|
|
options['fiscal_position'] = previous_fpos if previous_fpos in ('all', 'domestic') else 'all'
|
|
|
|
options['available_vat_fiscal_positions'] = [{
|
|
'id': fiscal_pos.id,
|
|
'name': fiscal_pos.name,
|
|
'company_id': fiscal_pos.company_id.id,
|
|
} for fiscal_pos in vat_fiscal_positions]
|
|
|
|
def _get_options_fiscal_position_domain(self, options):
|
|
def get_foreign_vat_tax_tag_extra_domain(fiscal_position=None):
|
|
# We want to gather any line wearing a tag, whatever its fiscal position.
|
|
# Nevertheless, if a country is using the same report for several regions (e.g. India) we need to exclude
|
|
# the lines from the other regions to avoid reporting numbers that don't belong to the current region.
|
|
fp_ids_to_exclude = self.env['account.fiscal.position'].search([
|
|
('id', '!=', fiscal_position.id if fiscal_position else False),
|
|
('foreign_vat', '!=', False),
|
|
('country_id', '=', self.country_id.id),
|
|
]).ids
|
|
|
|
if fiscal_position and fiscal_position.country_id == self.env.company.account_fiscal_country_id:
|
|
# We are looking for a fiscal position inside our country which means we need to exclude
|
|
# the local fiscal position which is represented by `False`.
|
|
fp_ids_to_exclude.append(False)
|
|
|
|
return [
|
|
('tax_tag_ids.country_id', '=', self.country_id.id),
|
|
('move_id.fiscal_position_id', 'not in', fp_ids_to_exclude),
|
|
]
|
|
|
|
fiscal_position_opt = options.get('fiscal_position')
|
|
|
|
if fiscal_position_opt == 'domestic':
|
|
domain = [
|
|
'|',
|
|
('move_id.fiscal_position_id', '=', False),
|
|
('move_id.fiscal_position_id.foreign_vat', '=', False),
|
|
]
|
|
tax_tag_domain = get_foreign_vat_tax_tag_extra_domain()
|
|
return osv.expression.OR([domain, tax_tag_domain])
|
|
|
|
if isinstance(fiscal_position_opt, int):
|
|
# It's a fiscal position id
|
|
domain = [('move_id.fiscal_position_id', '=', fiscal_position_opt)]
|
|
fiscal_position = self.env['account.fiscal.position'].browse(fiscal_position_opt)
|
|
tax_tag_domain = get_foreign_vat_tax_tag_extra_domain(fiscal_position)
|
|
return osv.expression.OR([domain, tax_tag_domain])
|
|
|
|
# 'all', or option isn't specified
|
|
return []
|
|
|
|
####################################################
|
|
# OPTIONS: MULTI COMPANY
|
|
####################################################
|
|
|
|
def _init_options_companies(self, options, previous_options):
|
|
if self.filter_multi_company == 'selector':
|
|
companies = self.env.companies
|
|
elif self.filter_multi_company == 'tax_units':
|
|
companies = self._multi_company_tax_units_init_options(options, previous_options=previous_options)
|
|
else:
|
|
# Multi-company is disabled for this report ; only accept the sub-branches of the current company from the selector
|
|
companies = self.env.company._accessible_branches()
|
|
|
|
options['companies'] = [{'name': c.name, 'id': c.id, 'currency_id': c.currency_id.id} for c in companies]
|
|
|
|
def _multi_company_tax_units_init_options(self, options, previous_options):
|
|
""" Initializes the companies option for reports configured to compute it from tax units.
|
|
"""
|
|
tax_units_domain = [('company_ids', 'in', self.env.company.id)]
|
|
|
|
if self.country_id:
|
|
tax_units_domain.append(('country_id', '=', self.country_id.id))
|
|
|
|
available_tax_units = self.env['account.tax.unit'].search(tax_units_domain)
|
|
|
|
# Filter available units to only consider the ones whose companies are all accessible to the user
|
|
available_tax_units = available_tax_units.filtered(
|
|
lambda x: all(unit_company in self.env.user.company_ids for unit_company in x.sudo().company_ids)
|
|
# sudo() to avoid bypassing companies the current user does not have access to
|
|
)
|
|
|
|
options['available_tax_units'] = [{
|
|
'id': tax_unit.id,
|
|
'name': tax_unit.name,
|
|
'company_ids': tax_unit.company_ids.ids
|
|
} for tax_unit in available_tax_units]
|
|
|
|
# Available tax_unit option values that are currently allowed by the company selector
|
|
# A js hack ensures the page is reloaded and the selected companies modified
|
|
# when clicking on a tax unit option in the UI, so we don't need to worry about that here.
|
|
companies_authorized_tax_unit_opt = {
|
|
*(available_tax_units.filtered(lambda x: set(self.env.companies) == set(x.company_ids)).ids),
|
|
'company_only'
|
|
}
|
|
|
|
if previous_options.get('tax_unit') in companies_authorized_tax_unit_opt:
|
|
options['tax_unit'] = previous_options['tax_unit']
|
|
|
|
else:
|
|
# No tax_unit gotten from previous options; initialize it
|
|
# A tax_unit will be set by default if only one tax unit is available for the report
|
|
# (which should always be true for non-generic reports, which have a country), and the companies of
|
|
# the unit are the only ones currently selected.
|
|
if companies_authorized_tax_unit_opt == {'company_only'}:
|
|
options['tax_unit'] = 'company_only'
|
|
elif len(available_tax_units) == 1 and available_tax_units[0].id in companies_authorized_tax_unit_opt:
|
|
options['tax_unit'] = available_tax_units[0].id
|
|
else:
|
|
options['tax_unit'] = 'company_only'
|
|
|
|
# Finally initialize multi_company filter
|
|
if options['tax_unit'] == 'company_only':
|
|
companies = self.env.company._get_branches_with_same_vat(accessible_only=True)
|
|
else:
|
|
tax_unit = available_tax_units.filtered(lambda x: x.id == options['tax_unit'])
|
|
companies = tax_unit.company_ids
|
|
|
|
return companies
|
|
|
|
####################################################
|
|
# OPTIONS: MULTI CURRENCY
|
|
####################################################
|
|
def _init_options_multi_currency(self, options, previous_options):
|
|
options['multi_currency'] = (
|
|
any([company.get('currency_id') != options['companies'][0].get('currency_id') for company in options['companies']])
|
|
or any([column.figure_type != 'monetary' for column in self.column_ids])
|
|
or any(expression.figure_type and expression.figure_type != 'monetary' for expression in self.line_ids.expression_ids)
|
|
)
|
|
|
|
####################################################
|
|
# OPTIONS: CURRENCY TABLE
|
|
####################################################
|
|
def _init_options_currency_table(self, options, previous_options):
|
|
companies = self.env['res.company'].browse(self.get_report_company_ids(options))
|
|
table_type = 'monocurrency' if self.env['res.currency']._check_currency_table_monocurrency(companies) else self.currency_translation
|
|
|
|
periods = {}
|
|
for col_group in options['column_groups'].values():
|
|
if col_group['forced_options'].get('no_impact_on_currency_table'):
|
|
# This key is used to ignore the colum group in the creation of the periods list for
|
|
# the currency table. This way, its dates won't influence. It's useful for groups corresponding
|
|
# to an initial balance of some sorts, like on the Trial Balance.
|
|
continue
|
|
|
|
col_group_date = col_group['forced_options'].get('date', options['date'])
|
|
|
|
col_group_date_from = col_group_date['date_from'] if col_group_date['mode'] == 'range' else None
|
|
col_group_date_to = col_group_date['date_to']
|
|
period_key = col_group_date['currency_table_period_key']
|
|
|
|
already_present_period = periods.get(period_key)
|
|
if already_present_period:
|
|
# This can happen for custom reports, needing to enforce the same rates on multiple column groups with
|
|
# different dates (e.g. Trial Balance). In that case, the date_from and date_to of the currency table period must respectively
|
|
# be the lowest and highest among those groups.
|
|
if col_group_date_from and already_present_period['from'] > col_group_date_from:
|
|
already_present_period['from'] = col_group_date_from
|
|
|
|
if already_present_period['to'] < col_group_date_to:
|
|
already_present_period['to'] = col_group_date_to
|
|
else:
|
|
periods[period_key] = {
|
|
'from': col_group_date_from,
|
|
'to': col_group_date_to,
|
|
}
|
|
|
|
options['currency_table'] = {'type': table_type, 'periods': periods}
|
|
|
|
@api.model
|
|
def _currency_table_apply_rate(self, value: SQL) -> SQL:
|
|
""" Returns an SQL term to use in a SELECT statement converting the value passed as parameter into the current company's currency, using the
|
|
currency table (which must be joined in the query as well ; using _currency_table_aml_join for account.move.line, or _get_currency_table for
|
|
other more specific uses).
|
|
"""
|
|
return SQL("(%(value)s) * COALESCE(account_currency_table.rate, 1)", value=value)
|
|
|
|
@api.model
|
|
def _currency_table_aml_join(self, options, aml_alias=SQL('account_move_line')) -> SQL:
|
|
""" Returns the JOIN condition to the currency table in a query needing to use it to convert aml balances from one currency to another.
|
|
"""
|
|
if options['currency_table']['type'] == 'cta':
|
|
return SQL(
|
|
"""
|
|
JOIN account_account aml_ct_account
|
|
ON aml_ct_account.id = %(aml_table)s.account_id
|
|
LEFT JOIN %(currency_table)s
|
|
ON %(aml_table)s.company_id = account_currency_table.company_id
|
|
AND (
|
|
account_currency_table.rate_type = CASE
|
|
WHEN aml_ct_account.account_type LIKE %(equity_prefix)s THEN 'historical'
|
|
WHEN aml_ct_account.account_type LIKE ANY (ARRAY[%(income_prefix)s, %(expense_prefix)s, 'equity_unaffected']) THEN 'average'
|
|
ELSE 'closing'
|
|
END
|
|
)
|
|
AND (account_currency_table.date_from IS NULL OR account_currency_table.date_from <= %(aml_table)s.date)
|
|
AND (account_currency_table.date_next IS NULL OR account_currency_table.date_next > %(aml_table)s.date)
|
|
AND (account_currency_table.period_key = %(period_key)s OR account_currency_table.period_key IS NULL)
|
|
""",
|
|
aml_table=aml_alias,
|
|
equity_prefix='equity%',
|
|
income_prefix='income%',
|
|
expense_prefix='expense%',
|
|
currency_table=self._get_currency_table(options),
|
|
period_key=options['date']['currency_table_period_key'],
|
|
)
|
|
|
|
return SQL(
|
|
"""
|
|
JOIN %(currency_table)s
|
|
ON %(aml_table)s.company_id = account_currency_table.company_id
|
|
AND (account_currency_table.period_key = %(period_key)s OR account_currency_table.period_key IS NULL)
|
|
""",
|
|
aml_table=aml_alias,
|
|
currency_table=self._get_currency_table(options),
|
|
period_key=options['date']['currency_table_period_key'],
|
|
)
|
|
|
|
@api.model
|
|
def _get_currency_table(self, options) -> SQL:
|
|
""" Returns the currency table table definition to be injected in the JOIN condition of an SQL query needing to use it.
|
|
"""
|
|
if options['currency_table']['type'] == 'monocurrency':
|
|
companies = self.env['res.company'].browse(self.get_report_company_ids(options))
|
|
return self.env['res.currency']._get_monocurrency_currency_table_sql(companies, use_cta_rates=options['currency_table']['type'] == 'cta')
|
|
|
|
return SQL('account_currency_table')
|
|
|
|
def _init_currency_table(self, options):
|
|
""" Creates the currency table temporary table if necessary, using the provided options to compute its periods.
|
|
This function should always be called before any query invovlving the currency table is run.
|
|
"""
|
|
if options['currency_table']['type'] != 'monocurrency':
|
|
companies = self.env['res.company'].browse(self.get_report_company_ids(options))
|
|
|
|
self.env['res.currency']._create_currency_table(
|
|
companies,
|
|
[(period_key, period['from'], period['to']) for period_key, period in options['currency_table']['periods'].items()],
|
|
use_cta_rates=options['currency_table']['type'] == 'cta',
|
|
)
|
|
|
|
####################################################
|
|
# OPTIONS: ROUNDING UNIT
|
|
####################################################
|
|
def _init_options_rounding_unit(self, options, previous_options):
|
|
default = 'decimals'
|
|
options['rounding_unit'] = previous_options.get('rounding_unit', default)
|
|
options['rounding_unit_names'] = self._get_rounding_unit_names()
|
|
|
|
def _get_rounding_unit_names(self):
|
|
currency_symbol = self.env.company.currency_id.symbol
|
|
currency_name = self.env.company.currency_id.name
|
|
|
|
rounding_unit_names = [
|
|
('decimals', (f'.{currency_symbol}', '')),
|
|
('units', (f'{currency_symbol}', '')),
|
|
('thousands', (f'K{currency_symbol}', _('Amounts in Thousands'))),
|
|
('millions', (f'M{currency_symbol}', _('Amounts in Millions'))),
|
|
]
|
|
|
|
if currency_name in CURRENCIES_USING_LAKH:
|
|
rounding_unit_names.insert(3, ('lakhs', (f'L{currency_symbol}', _('Amounts in Lakhs'))))
|
|
|
|
return dict(rounding_unit_names)
|
|
|
|
# ####################################################
|
|
# OPTIONS: ALL ENTRIES
|
|
####################################################
|
|
def _init_options_all_entries(self, options, previous_options):
|
|
if self.filter_show_draft:
|
|
options['all_entries'] = previous_options.get('all_entries', False)
|
|
else:
|
|
options['all_entries'] = False
|
|
|
|
####################################################
|
|
# OPTIONS: UNFOLDED LINES
|
|
####################################################
|
|
def _init_options_unfolded(self, options, previous_options):
|
|
options['unfold_all'] = self.filter_unfold_all and previous_options.get('unfold_all', False)
|
|
|
|
previous_section_source_id = previous_options.get('sections_source_id')
|
|
if not previous_section_source_id or previous_section_source_id == options['sections_source_id']:
|
|
# Only keep the unfolded lines if they belong to the same report or a section of the same report
|
|
options['unfolded_lines'] = previous_options.get('unfolded_lines', [])
|
|
else:
|
|
options['unfolded_lines'] = []
|
|
|
|
####################################################
|
|
# OPTIONS: HIDE LINE AT 0
|
|
####################################################
|
|
def _init_options_hide_0_lines(self, options, previous_options):
|
|
if self.filter_hide_0_lines != 'never':
|
|
previous_val = previous_options.get('hide_0_lines')
|
|
if previous_val is not None:
|
|
options['hide_0_lines'] = previous_val
|
|
else:
|
|
options['hide_0_lines'] = self.filter_hide_0_lines == 'by_default'
|
|
else:
|
|
options['hide_0_lines'] = False
|
|
|
|
def _filter_out_0_lines(self, lines):
|
|
""" Returns a list containing all lines that are not zero or that are parent to non-zero lines.
|
|
Can be used to ensure printed report does not include 0 lines, when hide_0_lines is toggled.
|
|
"""
|
|
lines_to_hide = set() # contain line ids to remove from lines
|
|
has_visible_children = set() # contain parent line ids
|
|
# Traverse lines in reverse to keep track of visible parent lines required by children lines
|
|
for line in reversed(lines):
|
|
is_zero_line = all(col.get('figure_type') not in NUMBER_FIGURE_TYPES or col.get('is_zero', True) for col in line['columns'])
|
|
if is_zero_line and line['id'] not in has_visible_children:
|
|
lines_to_hide.add(line['id'])
|
|
if line.get('parent_id') and line['id'] not in lines_to_hide:
|
|
has_visible_children.add(line['parent_id'])
|
|
return list(filter(lambda x: x['id'] not in lines_to_hide, lines))
|
|
|
|
####################################################
|
|
# OPTIONS: HORIZONTAL GROUP
|
|
####################################################
|
|
def _init_options_horizontal_groups(self, options, previous_options):
|
|
options['available_horizontal_groups'] = [
|
|
{
|
|
'id': horizontal_group.id,
|
|
'name': horizontal_group.name,
|
|
}
|
|
for horizontal_group in self.horizontal_group_ids
|
|
]
|
|
previous_selected = previous_options.get('selected_horizontal_group_id')
|
|
options['selected_horizontal_group_id'] = previous_selected if previous_selected in self.horizontal_group_ids.ids else None
|
|
|
|
####################################################
|
|
# OPTIONS: SEARCH BAR
|
|
####################################################
|
|
def _init_options_search_bar(self, options, previous_options):
|
|
if self.search_bar:
|
|
options['search_bar'] = True
|
|
if 'default_filter_accounts' not in self.env.context and 'filter_search_bar' in previous_options:
|
|
options['filter_search_bar'] = previous_options['filter_search_bar']
|
|
|
|
####################################################
|
|
# OPTIONS: COLUMN HEADERS
|
|
####################################################
|
|
|
|
def _init_options_column_headers(self, options, previous_options):
|
|
# Prepare column headers, in case the order of the comparison is ascending we reverse the order of the columns
|
|
all_comparison_date_vals = ([options['date']] + options.get('comparison', {}).get('periods', []))
|
|
if options.get('comparison') and options['comparison']['period_order'] == 'ascending':
|
|
all_comparison_date_vals = all_comparison_date_vals[::-1]
|
|
|
|
column_headers = [
|
|
[
|
|
{
|
|
'name': comparison_date_vals['string'],
|
|
'forced_options': {'date': comparison_date_vals},
|
|
}
|
|
for comparison_date_vals in all_comparison_date_vals
|
|
], # First level always consists of date comparison. Horizontal groupby are done on following levels.
|
|
]
|
|
|
|
# Handle horizontal groups
|
|
selected_horizontal_group_id = options.get('selected_horizontal_group_id')
|
|
if selected_horizontal_group_id:
|
|
horizontal_group = self.env['account.report.horizontal.group'].browse(selected_horizontal_group_id)
|
|
|
|
for field_name, records in horizontal_group._get_header_levels_data():
|
|
header_level = [
|
|
{
|
|
'name': record.display_name,
|
|
'horizontal_groupby_element': {field_name: record.id},
|
|
}
|
|
for record in records
|
|
]
|
|
column_headers.append(header_level)
|
|
else:
|
|
# Insert budget column headers if needed
|
|
selected_budgets = [budget for budget in options.get('budgets', []) if budget['selected']]
|
|
if selected_budgets:
|
|
budget_headers = [{
|
|
'name': '',
|
|
'forced_options': {
|
|
'budget_base': True,
|
|
}
|
|
}]
|
|
|
|
for budget in selected_budgets:
|
|
# Add budget amount column
|
|
budget_headers.append({
|
|
'name': budget['name'],
|
|
'forced_options': {
|
|
'compute_budget': budget['id'],
|
|
},
|
|
'colspan': 1,
|
|
})
|
|
if len(self.column_ids.filtered(lambda column: column.figure_type == 'monetary')) == 1:
|
|
# Add budget percentage column (only if one column in the report)
|
|
budget_headers.append({
|
|
'name': "%",
|
|
'forced_options': {
|
|
'budget_percentage': budget['id'],
|
|
},
|
|
'colspan': 1,
|
|
})
|
|
|
|
column_headers.append(budget_headers)
|
|
|
|
options['column_headers'] = column_headers
|
|
|
|
####################################################
|
|
# OPTIONS: COLUMNS
|
|
####################################################
|
|
def _init_options_columns(self, options, previous_options):
|
|
default_group_vals = {'horizontal_groupby_element': {}, 'forced_options': {}}
|
|
all_column_group_vals_in_order = self._generate_columns_group_vals_recursively(options['column_headers'], default_group_vals)
|
|
|
|
columns, column_groups = self._build_columns_from_column_group_vals(options, all_column_group_vals_in_order)
|
|
|
|
options['columns'] = columns
|
|
options['column_groups'] = column_groups
|
|
|
|
# Debug column is only shown when there is a single column group, so that we can display all the subtotals of the line in a clear way
|
|
options['show_debug_column'] = options['export_mode'] != 'print' \
|
|
and self.env.user.has_group('base.group_no_one') \
|
|
and len(options['column_groups']) == 1 \
|
|
and len(self.line_ids) > 0 # No debug column on fully dynamic reports by default (they can customize this)
|
|
|
|
# Show an additional column summing all the horizontal groups if there is no comparison and only one level of horizontal group
|
|
options['show_horizontal_group_total'] = options.get('selected_horizontal_group_id') \
|
|
and options.get('comparison', {}).get('filter') == 'no_comparison' \
|
|
and len(self.column_ids) == 1 \
|
|
and len(options['column_headers']) == 2
|
|
|
|
def _generate_columns_group_vals_recursively(self, next_levels_headers, previous_levels_group_vals):
|
|
if next_levels_headers:
|
|
rslt = []
|
|
for header_element in next_levels_headers[0]:
|
|
current_level_group_vals = {}
|
|
for key in previous_levels_group_vals:
|
|
current_level_group_vals[key] = {**previous_levels_group_vals.get(key, {}), **header_element.get(key, {})}
|
|
|
|
rslt += self._generate_columns_group_vals_recursively(next_levels_headers[1:], current_level_group_vals)
|
|
return rslt
|
|
else:
|
|
return [previous_levels_group_vals]
|
|
|
|
def _build_columns_from_column_group_vals(self, options, all_column_group_vals_in_order):
|
|
def _generate_domain_from_horizontal_group_hash_key_tuple(group_hash_key):
|
|
domain = []
|
|
for field_name, field_value in group_hash_key:
|
|
domain.append((field_name, '=', field_value))
|
|
return domain
|
|
|
|
columns = []
|
|
column_groups = {}
|
|
for column_group_val in all_column_group_vals_in_order:
|
|
horizontal_group_key_tuple = self._get_dict_hashable_key_tuple(column_group_val['horizontal_groupby_element']) # Empty tuple if no grouping
|
|
column_group_key = str(self._get_dict_hashable_key_tuple(column_group_val)) # Unique identifier for the column group
|
|
|
|
column_groups[column_group_key] = {
|
|
'forced_options': column_group_val['forced_options'],
|
|
'forced_domain': _generate_domain_from_horizontal_group_hash_key_tuple(horizontal_group_key_tuple),
|
|
}
|
|
|
|
# for budget, only one column in needed, regardless of the number of columns in the report
|
|
if any(budget_key in column_group_val['forced_options'] for budget_key in ('compute_budget', 'budget_percentage')):
|
|
columns.append({
|
|
'name': "",
|
|
'column_group_key': column_group_key,
|
|
'expression_label': 'balance',
|
|
'sortable': False,
|
|
'figure_type': 'monetary',
|
|
'blank_if_zero': False,
|
|
'style': "text-align: center; white-space: nowrap;",
|
|
})
|
|
|
|
else:
|
|
for report_column in self.column_ids:
|
|
columns.append({
|
|
'name': report_column.name,
|
|
'column_group_key': column_group_key,
|
|
'expression_label': report_column.expression_label,
|
|
'sortable': report_column.sortable,
|
|
'figure_type': report_column.figure_type,
|
|
'blank_if_zero': report_column.blank_if_zero,
|
|
'style': "text-align: center; white-space: nowrap;",
|
|
})
|
|
|
|
return columns, column_groups
|
|
|
|
def _get_dict_hashable_key_tuple(self, dict_to_convert):
|
|
rslt = []
|
|
for key, value in sorted(dict_to_convert.items()):
|
|
if isinstance(value, dict):
|
|
value = self._get_dict_hashable_key_tuple(value)
|
|
rslt.append((key, value))
|
|
return tuple(rslt)
|
|
|
|
####################################################
|
|
# OPTIONS: BUTTONS
|
|
####################################################
|
|
|
|
def action_open_report_form(self, options, params):
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.report',
|
|
'view_mode': 'form',
|
|
'views': [(False, 'form')],
|
|
'res_id': self.id,
|
|
}
|
|
|
|
def _init_options_buttons(self, options, previous_options):
|
|
options['buttons'] = [
|
|
{'name': _('PDF'), 'sequence': 10, 'action': 'export_file', 'action_param': 'export_to_pdf', 'file_export_type': _('PDF'), 'branch_allowed': True, 'always_show': True},
|
|
{'name': _('XLSX'), 'sequence': 20, 'action': 'export_file', 'action_param': 'export_to_xlsx', 'file_export_type': _('XLSX'), 'branch_allowed': True, 'always_show': True},
|
|
]
|
|
|
|
def open_account_report_file_download_error_wizard(self, errors, content):
|
|
self.ensure_one()
|
|
|
|
model = 'account.report.file.download.error.wizard'
|
|
vals = {'actionable_errors': errors}
|
|
|
|
if content:
|
|
vals['file_name'] = content['file_name']
|
|
vals['file_content'] = base64.b64encode(re.sub(r'\n\s*\n', '\n', content['file_content']).encode())
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': model,
|
|
'res_id': self.env[model].create(vals).id,
|
|
'target': 'new',
|
|
'views': [(False, 'form')],
|
|
}
|
|
|
|
def get_export_mime_type(self, file_type):
|
|
""" Returns the MIME type associated with a report export file type,
|
|
for attachment generation.
|
|
"""
|
|
type_mapping = {
|
|
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'pdf': 'application/pdf',
|
|
'xml': 'application/xml',
|
|
'xaf': 'application/vnd.sun.xml.writer',
|
|
'txt': 'text/plain',
|
|
'csv': 'text/csv',
|
|
'zip': 'application/zip',
|
|
}
|
|
return type_mapping.get(file_type, False)
|
|
|
|
def _init_options_section_buttons(self, options, previous_options):
|
|
""" In case we're displaying a section, we want to replace its buttons by its source report's. This needs to be done last, after calling the
|
|
custom handler, to avoid its _custom_options_initializer function to generate additional buttons.
|
|
"""
|
|
if options['sections_source_id'] != self.id:
|
|
# We need to re-call a full get_options in case a custom options initializer adds new buttons depending on other options.
|
|
# This way, we're sure we always get all buttons that are needed.
|
|
sections_source = self.env['account.report'].browse(options['sections_source_id'])
|
|
options['buttons'] = sections_source.get_options(previous_options={**options, 'no_report_reroute': True})['buttons']
|
|
|
|
####################################################
|
|
# OPTIONS: VARIANTS
|
|
####################################################
|
|
def _init_options_variants(self, options, previous_options):
|
|
allowed_variant_ids = set()
|
|
|
|
previous_section_source_id = previous_options.get('sections_source_id')
|
|
if previous_section_source_id:
|
|
previous_section_source = self.env['account.report'].browse(previous_section_source_id)
|
|
if self in previous_section_source.section_report_ids:
|
|
options['variants_source_id'] = (previous_section_source.root_report_id or previous_section_source).id
|
|
allowed_variant_ids.add(previous_section_source_id)
|
|
|
|
if 'variants_source_id' not in options:
|
|
options['variants_source_id'] = (self.root_report_id or self).id
|
|
|
|
available_variants = self.env['account.report']
|
|
options['has_inactive_variants'] = False
|
|
allowed_country_variant_ids = {}
|
|
all_variants = self._get_variants(options['variants_source_id'])
|
|
for variant in all_variants.filtered(lambda x: x._is_available_for(options)):
|
|
if not self.root_report_id and variant != self and variant.active: # Non-route reports don't reroute the variant when computing their options
|
|
allowed_variant_ids.add(variant.id)
|
|
if variant.country_id:
|
|
allowed_country_variant_ids.setdefault(variant.country_id.id, []).append(variant.id)
|
|
|
|
if variant.active:
|
|
available_variants += variant
|
|
else:
|
|
options['has_inactive_variants'] = True
|
|
|
|
options['available_variants'] = [
|
|
{
|
|
'id': variant.id,
|
|
'name': variant.display_name,
|
|
'country_id': variant.country_id.id, # To ease selection of default variant to open, without needing browsing again
|
|
}
|
|
for variant in sorted(available_variants, key=lambda x: (x.country_id and 1 or 0, x.sequence, x.id))
|
|
]
|
|
|
|
previous_opt_report_id = previous_options.get('selected_variant_id')
|
|
if previous_opt_report_id in allowed_variant_ids or previous_opt_report_id == self.id:
|
|
options['selected_variant_id'] = previous_opt_report_id
|
|
elif allowed_country_variant_ids:
|
|
country_id = self.env.company.account_fiscal_country_id.id
|
|
report_id = (allowed_country_variant_ids.get(country_id) or next(iter(allowed_country_variant_ids.values())))[0]
|
|
options['selected_variant_id'] = report_id
|
|
else:
|
|
options['selected_variant_id'] = self.id
|
|
|
|
def _get_variants(self, report_id):
|
|
source_report = self.env['account.report'].browse(report_id)
|
|
if source_report.root_report_id:
|
|
# We need to get the root report in order to get all variants
|
|
source_report = source_report.root_report_id
|
|
return source_report + source_report.with_context(active_test=False).variant_report_ids
|
|
|
|
####################################################
|
|
# OPTIONS: SECTIONS
|
|
####################################################
|
|
def _init_options_sections(self, options, previous_options):
|
|
if options.get('selected_variant_id'):
|
|
options['sections_source_id'] = options['selected_variant_id']
|
|
else:
|
|
options['sections_source_id'] = self.id
|
|
|
|
source_report = self.env['account.report'].browse(options['sections_source_id'])
|
|
|
|
available_sections = source_report.section_report_ids if source_report.use_sections else self.env['account.report']
|
|
options['sections'] = [{'name': section.name, 'id': section.id} for section in available_sections]
|
|
|
|
if available_sections:
|
|
section_id = previous_options.get('selected_section_id')
|
|
if not section_id or section_id not in available_sections.ids:
|
|
section_id = available_sections[0].id
|
|
|
|
options['selected_section_id'] = section_id
|
|
|
|
options['has_inactive_sections'] = bool(self.env['account.report'].with_context(active_test=False).search_count([
|
|
('section_main_report_ids', 'in', options['sections_source_id']),
|
|
('active', '=', False)
|
|
]))
|
|
|
|
####################################################
|
|
# OPTIONS: REPORT_ID
|
|
####################################################
|
|
def _init_options_report_id(self, options, previous_options):
|
|
if previous_options.get('no_report_reroute'):
|
|
# Used for exports
|
|
options['report_id'] = self.id
|
|
else:
|
|
options['report_id'] = options.get('selected_section_id') or options.get('selected_variant_id') or self.id
|
|
|
|
####################################################
|
|
# OPTIONS: EXPORT MODE
|
|
####################################################
|
|
def _init_options_export_mode(self, options, previous_options):
|
|
options['export_mode'] = previous_options.get('export_mode')
|
|
|
|
####################################################
|
|
# OPTIONS: HORIZONTAL SPLIT
|
|
####################################################
|
|
def _init_options_horizontal_split(self, options, previous_options):
|
|
if any(line.horizontal_split_side for line in self.line_ids):
|
|
options['horizontal_split'] = previous_options.get('horizontal_split', False)
|
|
|
|
####################################################
|
|
# OPTIONS: CUSTOM
|
|
####################################################
|
|
def _init_options_custom(self, options, previous_options):
|
|
custom_handler_model = self._get_custom_handler_model()
|
|
if custom_handler_model:
|
|
self.env[custom_handler_model]._custom_options_initializer(self, options, previous_options)
|
|
|
|
####################################################
|
|
# OPTIONS: INTEGER ROUNDING
|
|
####################################################
|
|
def _init_options_integer_rounding(self, options, previous_options):
|
|
if self.integer_rounding:
|
|
options['integer_rounding'] = self.integer_rounding
|
|
if options.get('export_mode') == 'file':
|
|
options['integer_rounding_enabled'] = True
|
|
else:
|
|
options['integer_rounding_enabled'] = previous_options.get('integer_rounding_enabled', True)
|
|
return options
|
|
|
|
####################################################
|
|
# OPTIONS: BUDGETS
|
|
####################################################
|
|
def _init_options_budgets(self, options, previous_options):
|
|
if self.filter_budgets:
|
|
previous_selection = {budget_option['id'] for budget_option in previous_options.get('budgets', []) if budget_option.get('selected')}
|
|
|
|
options['budgets'] = [
|
|
{
|
|
'id': budget.id,
|
|
'name': budget.name,
|
|
'selected': budget.id in previous_selection,
|
|
'company_id': budget.company_id.id,
|
|
}
|
|
for budget in self.env['account.report.budget'].search([('company_id', '=', self.env.company.id)])
|
|
]
|
|
options['show_all_accounts'] = previous_options.get('show_all_accounts') or False
|
|
|
|
####################################################
|
|
# OPTIONS: LOADING CALL
|
|
####################################################
|
|
def _init_options_loading_call(self, options, previous_options):
|
|
""" Used by the js to know if it needs to reload the options (to not overwrite new options from the js) """
|
|
options['loading_call_number'] = previous_options.get('loading_call_number') or 0
|
|
return options
|
|
|
|
####################################################
|
|
# OPTIONS: READONLY QUERY
|
|
####################################################
|
|
def _init_options_readonly_query(self, options, previous_options):
|
|
options['readonly_query'] = (
|
|
options['currency_table']['type'] == 'monocurrency'
|
|
and not any(budget_opt['selected'] for budget_opt in options.get('budgets', []))
|
|
)
|
|
|
|
####################################################
|
|
# OPTIONS: CORE
|
|
####################################################
|
|
|
|
@api.readonly
|
|
def get_options(self, previous_options):
|
|
self.ensure_one()
|
|
|
|
initializers_in_sequence = self._get_options_initializers_in_sequence()
|
|
|
|
options = {}
|
|
|
|
if previous_options.get('_running_export_test'):
|
|
options['_running_export_test'] = True
|
|
|
|
# We need report_id to be initialized. Compute the necessary options to check for reroute.
|
|
for reroute_initializer_index, initializer in enumerate(initializers_in_sequence):
|
|
initializer(options, previous_options=previous_options)
|
|
|
|
# pylint: disable=W0143
|
|
if initializer == self._init_options_report_id:
|
|
break
|
|
|
|
# Stop the computation to check for reroute once we have computed the necessary information
|
|
if (not self.root_report_id or (self.use_sections and self.section_report_ids)) and options['report_id'] != self.id:
|
|
# Load the variant/section instead of the root report
|
|
variant_options = {**previous_options}
|
|
for reroute_opt_key in ('selected_variant_id', 'selected_section_id', 'variants_source_id', 'sections_source_id'):
|
|
opt_val = options.get(reroute_opt_key)
|
|
if opt_val:
|
|
variant_options[reroute_opt_key] = opt_val
|
|
|
|
return self.env['account.report'].browse(options['report_id']).get_options(variant_options)
|
|
|
|
# No reroute; keep on and compute the other options
|
|
for initializer_index in range(reroute_initializer_index + 1, len(initializers_in_sequence)):
|
|
initializer = initializers_in_sequence[initializer_index]
|
|
initializer(options, previous_options=previous_options)
|
|
|
|
# Sort the buttons list by sequence, for rendering
|
|
options_companies = self.env['res.company'].browse(self.get_report_company_ids(options))
|
|
if not options_companies._all_branches_selected():
|
|
for button in filter(lambda x: not x.get('branch_allowed'), options['buttons']):
|
|
button['error_action'] = 'show_error_branch_allowed'
|
|
|
|
options['buttons'] = sorted(options['buttons'], key=lambda x: x.get('sequence', 90))
|
|
|
|
return options
|
|
|
|
def _get_options_initializers_in_sequence(self):
|
|
""" Gets all filters in the right order to initialize them, so that each filter is
|
|
guaranteed to be after all of its dependencies in the resulting list.
|
|
|
|
:return: a list of initializer functions, each accepting two parameters:
|
|
- options (mandatory): The options dictionary to be modified by this initializer to include its related option's data
|
|
|
|
- previous_options (optional, defaults to None): A dict with default options values, coming from a previous call to the report.
|
|
These values can be considered or ignored on a case-by-case basis by the initializer,
|
|
depending on functional needs.
|
|
"""
|
|
initializer_prefix = '_init_options_'
|
|
initializers = [
|
|
getattr(self, attr) for attr in dir(self)
|
|
if attr.startswith(initializer_prefix)
|
|
]
|
|
|
|
# Order them in a dependency-compliant way
|
|
forced_sequence_map = self._get_options_initializers_forced_sequence_map()
|
|
initializers.sort(key=lambda x: forced_sequence_map.get(x, forced_sequence_map.get('default')))
|
|
|
|
return initializers
|
|
|
|
def _get_options_initializers_forced_sequence_map(self):
|
|
""" By default, not specific order is ensured for the filters when calling _get_options_initializers_in_sequence.
|
|
This function allows giving them a sequence number. It can be overridden
|
|
to make filters depend on each other.
|
|
|
|
:return: dict(str, int): str is the filter name, int is its sequence (lowest = first).
|
|
Multiple filters may share the same sequence, their relative order is then not guaranteed.
|
|
"""
|
|
return {
|
|
self._init_options_companies: 10,
|
|
self._init_options_variants: 15,
|
|
self._init_options_sections: 16,
|
|
self._init_options_report_id: 17,
|
|
self._init_options_fiscal_position: 20,
|
|
self._init_options_date: 30,
|
|
self._init_options_horizontal_groups: 40,
|
|
self._init_options_comparison: 50,
|
|
self._init_options_export_mode: 60,
|
|
self._init_options_integer_rounding: 70,
|
|
|
|
'default': 200,
|
|
|
|
self._init_options_column_headers: 990,
|
|
self._init_options_columns: 1000,
|
|
self._init_options_column_percent_comparison: 1010,
|
|
self._init_options_order_column: 1020,
|
|
self._init_options_hierarchy: 1030,
|
|
self._init_options_prefix_groups_threshold: 1040,
|
|
self._init_options_custom: 1050,
|
|
self._init_options_currency_table: 1055,
|
|
self._init_options_section_buttons: 1060,
|
|
self._init_options_readonly_query: 1070,
|
|
}
|
|
|
|
def _get_options_domain(self, options, date_scope):
|
|
self.ensure_one()
|
|
|
|
available_scopes = dict(self.env['account.report.expression']._fields['date_scope'].selection)
|
|
if date_scope and date_scope not in available_scopes: # date_scope can be passed to None explicitly to ignore the dates
|
|
raise UserError(_("Unknown date scope: %s", date_scope))
|
|
|
|
domain = [
|
|
('display_type', 'not in', ('line_section', 'line_note')),
|
|
('company_id', 'in', self.get_report_company_ids(options)),
|
|
]
|
|
if not options.get('compute_budget'):
|
|
domain += self._get_options_journals_domain(options)
|
|
if date_scope:
|
|
domain += self._get_options_date_domain(options, date_scope)
|
|
domain += self._get_options_partner_domain(options)
|
|
domain += self._get_options_all_entries_domain(options)
|
|
domain += self._get_options_unreconciled_domain(options)
|
|
domain += self._get_options_fiscal_position_domain(options)
|
|
domain += self._get_options_account_type_domain(options)
|
|
domain += self._get_options_aml_ir_filters(options)
|
|
|
|
if self.only_tax_exigible:
|
|
domain += self.env['account.move.line']._get_tax_exigible_domain()
|
|
|
|
if options.get('forced_domain'):
|
|
# That option key is set when splitting options between column groups
|
|
domain += options['forced_domain']
|
|
|
|
return domain
|
|
|
|
####################################################
|
|
# QUERIES
|
|
####################################################
|
|
|
|
def _get_report_query(self, options, date_scope, domain=None) -> Query:
|
|
""" Get a Query object that references the records needed for this report. """
|
|
domain = self._get_options_domain(options, date_scope) + (domain or [])
|
|
|
|
self.env['account.move.line'].check_access('read')
|
|
|
|
query = self.env['account.move.line']._where_calc(domain)
|
|
|
|
if options.get('compute_budget'):
|
|
self._create_report_budget_temp_table(options)
|
|
query._tables['account_move_line'] = SQL.identifier('account_report_budget_temp_aml')
|
|
query.add_where(SQL(
|
|
"%s AND budget_id = %s",
|
|
query.where_clause,
|
|
options['compute_budget'],
|
|
))
|
|
|
|
# Wrap the query with 'company_id IN (...)' to avoid bypassing company access rights.
|
|
self.env['account.move.line']._apply_ir_rules(query)
|
|
|
|
return query
|
|
|
|
def _create_report_budget_temp_table(self, options):
|
|
self.env.cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name='account_report_budget_temp_aml'")
|
|
if self.env.cr.fetchone():
|
|
return
|
|
|
|
stored_aml_fields, fields_to_insert = self.env['account.move.line']._prepare_aml_shadowing_for_report({
|
|
'id': SQL.identifier("id"),
|
|
'balance': SQL.identifier('amount'),
|
|
'company_id': self.env.company.id,
|
|
'parent_state': 'posted',
|
|
'date': SQL.identifier('date'),
|
|
'account_id': SQL.identifier("account_id"),
|
|
'debit': SQL("CASE WHEN (amount > 0) THEN amount else 0 END"),
|
|
'credit': SQL("CASE WHEN (amount < 0) THEN -amount else 0 END"),
|
|
})
|
|
|
|
self.env.cr.execute(SQL(
|
|
"""
|
|
-- Create a temporary table, dropping not null constraints because we're not filling those columns
|
|
CREATE TEMPORARY TABLE IF NOT EXISTS account_report_budget_temp_aml () inherits (account_move_line) ON COMMIT DROP;
|
|
ALTER TABLE account_report_budget_temp_aml NO INHERIT account_move_line;
|
|
ALTER TABLE account_report_budget_temp_aml ALTER COLUMN move_id DROP NOT NULL;
|
|
ALTER TABLE account_report_budget_temp_aml ALTER COLUMN currency_id DROP NOT NULL;
|
|
ALTER TABLE account_report_budget_temp_aml ALTER COLUMN journal_id DROP NOT NULL;
|
|
ALTER TABLE account_report_budget_temp_aml ALTER COLUMN display_type DROP NOT NULL;
|
|
ALTER TABLE account_report_budget_temp_aml ADD budget_id INTEGER NOT NULL;
|
|
|
|
INSERT INTO account_report_budget_temp_aml (%(stored_aml_fields)s, budget_id)
|
|
SELECT %(fields_to_insert)s, budget_id
|
|
FROM account_report_budget_item
|
|
WHERE budget_id IN %(available_budget_ids)s;
|
|
|
|
-- Create a supporting index to avoid seq.scans
|
|
CREATE INDEX IF NOT EXISTS account_report_budget_temp_aml__composite_idx ON account_report_budget_temp_aml (account_id, journal_id, date, company_id);
|
|
-- Update statistics for correct planning
|
|
ANALYZE account_report_budget_temp_aml
|
|
""",
|
|
stored_aml_fields=stored_aml_fields,
|
|
fields_to_insert=fields_to_insert,
|
|
available_budget_ids=tuple(budget_option['id'] for budget_option in options['budgets']),
|
|
))
|
|
|
|
if options.get('show_all_accounts'):
|
|
stored_aml_fields, fields_to_insert = self.env['account.move.line']._prepare_aml_shadowing_for_report({
|
|
# Using nextval will consume a sequence number, we decide to do it to avoid comparing apples and oranges
|
|
'id': SQL("(SELECT nextval('account_report_budget_item_id_seq'))"),
|
|
'balance': SQL("0"),
|
|
'company_id': self.env.company.id,
|
|
'parent_state': 'posted',
|
|
'date': SQL("%s", options['date']['date_from']),
|
|
'account_id': SQL.identifier("accounts", "id"),
|
|
'debit': SQL("0"),
|
|
'credit': SQL("0"),
|
|
})
|
|
accounts_subquery = self.env['account.account']._where_calc([
|
|
('company_ids', 'in', self.get_report_company_ids(options)),
|
|
('internal_group', 'in', ['income', 'expense']),
|
|
])
|
|
self.env.cr.execute(SQL(
|
|
"""
|
|
-- Insert dynamic combinations of account_id and budget_id into the temporary table
|
|
INSERT INTO account_report_budget_temp_aml (%(stored_aml_fields)s, budget_id)
|
|
SELECT %(fields_to_insert)s, budgets.id AS budget_id
|
|
FROM (%(accounts_subquery)s) AS accounts
|
|
CROSS JOIN (
|
|
SELECT id
|
|
FROM account_report_budget
|
|
WHERE id IN %(available_budget_ids)s
|
|
) AS budgets
|
|
""",
|
|
stored_aml_fields=stored_aml_fields,
|
|
fields_to_insert=fields_to_insert,
|
|
accounts_subquery=accounts_subquery.select(),
|
|
available_budget_ids=tuple(budget_option['id'] for budget_option in options['budgets']),
|
|
income='income%',
|
|
expense='expense%',
|
|
company_ids=tuple(),
|
|
))
|
|
|
|
####################################################
|
|
# LINE IDS MANAGEMENT HELPERS
|
|
####################################################
|
|
def _get_generic_line_id(self, model_name, value, markup=None, parent_line_id=None):
|
|
""" Generates a generic line id from the provided parameters.
|
|
|
|
Such a generic id consists of a string repeating 1 to n times the following pattern:
|
|
markup-model-value, each occurence separated by a LINE_ID_HIERARCHY_DELIMITER character from the previous one.
|
|
|
|
Each pattern corresponds to a level of hierarchy in the report, so that
|
|
the n-1 patterns starting the id of a line actually form the id of its generator line.
|
|
EX: a~b~c|d~e~f|g~h~i => This line is a subline generated by a~b~c|d~e~f where | is the LINE_ID_HIERARCHY_DELIMITER.
|
|
|
|
Each pattern consists of the three following elements:
|
|
- markup: a (possibly empty) free string or json-formatted dict allowing finer identification of the line
|
|
(like the name of the field for account.accounting.reports)
|
|
|
|
- model: the model this line has been generated for, or an empty string if there is none
|
|
|
|
- value: the groupby value for this line (typically the id of a record
|
|
or the value of a field), or an empty string if there isn't any.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if parent_line_id:
|
|
parent_id_list = self._parse_line_id(parent_line_id, markup_as_string=True)
|
|
else:
|
|
parent_id_list = [(None, 'account.report', self.id)]
|
|
|
|
# In case the markup is a dict, it must be converted to a string, but in a way such that the keys are ordered alphabetically.
|
|
# This is useful, notably for annotations where the ids of the lines are stored, therefore requiring a consistent ordering
|
|
if isinstance(markup, dict):
|
|
markup = json.dumps(markup, sort_keys=True)
|
|
|
|
new_line = self._build_line_id(parent_id_list + [(markup, model_name, value)])
|
|
return new_line
|
|
|
|
@api.model
|
|
def _get_model_info_from_id(self, line_id):
|
|
""" Parse the provided generic report line id.
|
|
|
|
:param line_id: the report line id (i.e. markup~model~value|markup2~model2~value2 where | is the LINE_ID_HIERARCHY_DELIMITER)
|
|
:return: tuple(model, id) of the report line. Each of those values can be None if the id contains no information about them.
|
|
"""
|
|
last_id_tuple = self._parse_line_id(line_id)[-1]
|
|
return last_id_tuple[-2:]
|
|
|
|
@api.model
|
|
def _build_line_id(self, current):
|
|
""" Build a generic line id string from its list representation, converting
|
|
the None values for model and value to empty strings.
|
|
:param current (list<tuple>): list of tuple(markup, model, value)
|
|
"""
|
|
def convert_none(x):
|
|
return x if x is not None and x is not False else ''
|
|
return LINE_ID_HIERARCHY_DELIMITER.join(f'{convert_none(markup)}~{convert_none(model)}~{convert_none(value)}' for markup, model, value in current)
|
|
|
|
@api.model
|
|
def _build_parent_line_id(self, current):
|
|
"""Build the parent_line id based on the current position in the report.
|
|
|
|
For instance, if current is [('markup1', 'account.account', 5), ('markup2', 'res.partner', 8)], it will return
|
|
markup1~account.account~5
|
|
:param current (list<tuple>): list of tuple(markup, model, value)
|
|
"""
|
|
return self._build_line_id(current[:-1])
|
|
|
|
@api.model
|
|
def _parse_markup(self, markup):
|
|
if not markup:
|
|
return markup
|
|
try:
|
|
result = json.loads(markup)
|
|
except json.JSONDecodeError: # the markup is not a JSON object
|
|
return markup
|
|
if isinstance(result, dict):
|
|
return result
|
|
|
|
return markup
|
|
|
|
@api.model
|
|
def _parse_line_id(self, line_id, markup_as_string=False):
|
|
"""Parse the provided string line id and convert it to its list representation.
|
|
Empty strings for model and value will be converted to None.
|
|
|
|
For instance if line_id is markup1~account.account~5|markup2~res.partner~8 (where | is the LINE_ID_HIERARCHY_DELIMITER),
|
|
it will return [('markup1', 'account.account', 5), ('markup2', 'res.partner', 8)]
|
|
:param line_id (str): the generic line id to parse
|
|
"""
|
|
return line_id and [
|
|
# When there is a model, value is an id, so we cast it to and int. Else, we keep the original value (for groupby lines on
|
|
# non-relational fields, for example).
|
|
(self._parse_markup(markup) if not markup_as_string else markup, model or None, int(value) if model and value else (value or None))
|
|
for markup, model, value in (key.rsplit('~', 2) for key in line_id.split(LINE_ID_HIERARCHY_DELIMITER))
|
|
] or []
|
|
|
|
@api.model
|
|
def _get_unfolded_lines(self, lines, parent_line_id):
|
|
""" Return a list of all children lines for specified parent_line_id.
|
|
NB: It will return the parent_line itself!
|
|
|
|
For instance if parent_line_ids is '~account.report.line~84|{"groupby": "currency_id"}~res.currency~174'
|
|
(where | is the LINE_ID_HIERARCHY_DELIMITER), it will return every subline for this currency.
|
|
:param lines: list of report lines
|
|
:param parent_line_id: id of a specified line
|
|
:return: A list of all children lines for a specified parent_line_id
|
|
"""
|
|
return [
|
|
line for line in lines
|
|
if line['id'].startswith(parent_line_id)
|
|
]
|
|
|
|
@api.model
|
|
def _get_res_id_from_line_id(self, line_id, target_model_name):
|
|
""" Parses the provided generic line id and returns the most local (i.e. the furthest on the right) record id it contains which
|
|
corresponds to the provided model name. If line_id does not contain anything related to target_model_name, None will be returned.
|
|
|
|
For example, parsing ~account.move~1|~res.partner~2|~account.move~3 (where | is the LINE_ID_HIERARCHY_DELIMITER)
|
|
with target_model_name='account.move' will return 3.
|
|
"""
|
|
dict_result = self._get_res_ids_from_line_id(line_id, [target_model_name])
|
|
return dict_result[target_model_name] if dict_result else None
|
|
|
|
|
|
@api.model
|
|
def _get_res_ids_from_line_id(self, line_id, target_model_names):
|
|
""" Parses the provided generic line id and returns the most local (i.e. the furthest on the right) record ids it contains which
|
|
correspond to the provided model names, in the form {model_name: res_id}. If a model is not present in line_id, its model will be absent
|
|
from the resulting dict.
|
|
|
|
For example, parsing ~account.move~1|~res.partner~2|~account.move~3 with target_model_names=['account.move', 'res.partner'] will return
|
|
{'account.move': 3, 'res.partner': 2}.
|
|
"""
|
|
result = {}
|
|
models_to_find = set(target_model_names)
|
|
for dummy, model, value in reversed(self._parse_line_id(line_id)):
|
|
if model in models_to_find:
|
|
result[model] = value
|
|
models_to_find.remove(model)
|
|
|
|
return result
|
|
|
|
@api.model
|
|
def _get_markup(self, line_id):
|
|
""" Directly returns the markup associated with the provided line_id.
|
|
"""
|
|
return self._parse_line_id(line_id)[-1][0] if line_id else None
|
|
|
|
def _build_subline_id(self, parent_line_id, subline_id_postfix):
|
|
""" Creates a new subline id by concatanating parent_line_id with the provided id postfix.
|
|
"""
|
|
return f"{parent_line_id}{LINE_ID_HIERARCHY_DELIMITER}{subline_id_postfix}"
|
|
|
|
####################################################
|
|
# CARET OPTIONS MANAGEMENT
|
|
####################################################
|
|
|
|
def _get_caret_options(self):
|
|
if self.custom_handler_model_id:
|
|
return self.env[self.custom_handler_model_name]._caret_options_initializer()
|
|
return self._caret_options_initializer_default()
|
|
|
|
def _caret_options_initializer_default(self):
|
|
return {
|
|
'account.account': [
|
|
{'name': _("General Ledger"), 'action': 'caret_option_open_general_ledger'},
|
|
],
|
|
|
|
'account.move': [
|
|
{'name': _("View Journal Entry"), 'action': 'caret_option_open_record_form'},
|
|
],
|
|
|
|
'account.move.line': [
|
|
{'name': _("View Journal Entry"), 'action': 'caret_option_open_record_form', 'action_param': 'move_id'},
|
|
],
|
|
|
|
'account.payment': [
|
|
{'name': _("View Payment"), 'action': 'caret_option_open_record_form', 'action_param': 'payment_id'},
|
|
],
|
|
|
|
'account.bank.statement': [
|
|
{'name': _("View Bank Statement"), 'action': 'caret_option_open_statement_line_reco_widget'},
|
|
],
|
|
|
|
'res.partner': [
|
|
{'name': _("View Partner"), 'action': 'caret_option_open_record_form'},
|
|
],
|
|
}
|
|
|
|
def caret_option_open_record_form(self, options, params):
|
|
model, record_id = self._get_model_info_from_id(params['line_id'])
|
|
record = self.env[model].browse(record_id)
|
|
target_record = record[params['action_param']] if 'action_param' in params else record
|
|
|
|
view_id = self._resolve_caret_option_view(target_record)
|
|
|
|
action = {
|
|
'type': 'ir.actions.act_window',
|
|
'view_mode': 'form',
|
|
'views': [(view_id, 'form')], # view_id will be False in case the default view is needed
|
|
'res_model': target_record._name,
|
|
'res_id': target_record.id,
|
|
'context': self.env.context,
|
|
}
|
|
|
|
if view_id is not None:
|
|
action['view_id'] = view_id
|
|
|
|
return action
|
|
|
|
def _get_caret_option_view_map(self):
|
|
return {
|
|
'account.payment': 'account.view_account_payment_form',
|
|
'res.partner': 'base.view_partner_form',
|
|
'account.move': 'account.view_move_form',
|
|
}
|
|
|
|
def _resolve_caret_option_view(self, target):
|
|
'''Retrieve the target view of the caret option.
|
|
|
|
:param target: The target record of the redirection.
|
|
:return: The id of the target view.
|
|
'''
|
|
view_map = self._get_caret_option_view_map()
|
|
|
|
view_xmlid = view_map.get(target._name)
|
|
if not view_xmlid:
|
|
return None
|
|
|
|
return self.env['ir.model.data']._xmlid_lookup(view_xmlid)[1]
|
|
|
|
def caret_option_open_general_ledger(self, options, params):
|
|
# When coming from a specific account, the unfold must only be retained
|
|
# on the specified account. Better performance and more ergonomic
|
|
# as it opens what client asked. And "Unfold All" is 1 clic away.
|
|
options["unfold_all"] = False
|
|
|
|
records_to_unfold = []
|
|
for _dummy, model, record_id in self._parse_line_id(params['line_id']):
|
|
if model in ('account.group', 'account.account'):
|
|
records_to_unfold.append((model, record_id))
|
|
|
|
if not records_to_unfold or records_to_unfold[-1][0] != 'account.account':
|
|
raise UserError(_("'Open General Ledger' caret option is only available form report lines targetting accounts."))
|
|
|
|
general_ledger = self.env.ref('fusion_accounting.general_ledger_report')
|
|
lines_to_unfold = []
|
|
for model, record_id in records_to_unfold:
|
|
parent_line_id = lines_to_unfold[-1] if lines_to_unfold else None
|
|
# Re-create the hierarchy of account groups that should be unfolded in GL
|
|
generic_line_id = general_ledger._get_generic_line_id(model, record_id, parent_line_id=parent_line_id)
|
|
lines_to_unfold.append(generic_line_id)
|
|
|
|
options['not_reset_journals_filter'] = True # prevents resetting the default journal group
|
|
gl_options = general_ledger.get_options(options)
|
|
gl_options['not_reset_journals_filter'] = True # prevents resetting the default journal group
|
|
gl_options['unfolded_lines'] = lines_to_unfold
|
|
|
|
account_id = self.env['account.account'].browse(records_to_unfold[-1][1])
|
|
action_vals = self.env['ir.actions.actions']._for_xml_id('fusion_accounting.action_account_report_general_ledger')
|
|
action_vals['params'] = {
|
|
'options': gl_options,
|
|
'ignore_session': True,
|
|
}
|
|
action_vals['context'] = dict(ast.literal_eval(action_vals['context']), default_filter_accounts=account_id.code)
|
|
|
|
return action_vals
|
|
|
|
def caret_option_open_statement_line_reco_widget(self, options, params):
|
|
model, record_id = self._get_model_info_from_id(params['line_id'])
|
|
record = self.env[model].browse(record_id)
|
|
if record._name == 'account.bank.statement.line':
|
|
return record.action_open_recon_st_line()
|
|
elif record._name == 'account.bank.statement':
|
|
return record.action_open_bank_reconcile_widget()
|
|
raise UserError(_("'View Bank Statement' caret option is only available for report lines targeting bank statements."))
|
|
|
|
####################################################
|
|
# MISC
|
|
####################################################
|
|
|
|
def _get_custom_handler_model(self):
|
|
""" Check whether the current report has a custom handler and if it does, return its name.
|
|
Otherwise, try to fall back on the root report.
|
|
"""
|
|
return self.custom_handler_model_name or self.root_report_id.custom_handler_model_name or None
|
|
|
|
def dispatch_report_action(self, options, action, action_param=None, on_sections_source=False):
|
|
""" Dispatches calls made by the client to either the report itself, or its custom handler if it exists.
|
|
The action should be a public method, by definition, but a check is made to make sure
|
|
it is not trying to call a private method.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if on_sections_source:
|
|
report_to_call = self.env['account.report'].browse(options['sections_source_id'])
|
|
return report_to_call.dispatch_report_action(options, action, action_param=action_param, on_sections_source=False)
|
|
|
|
if self.id not in (options['report_id'], options.get('sections_source_id')):
|
|
raise UserError(_("Trying to dispatch an action on a report not compatible with the provided options."))
|
|
|
|
check_method_name(action)
|
|
args = [options, action_param] if action_param is not None else [options]
|
|
custom_handler_model = self._get_custom_handler_model()
|
|
if custom_handler_model and hasattr(self.env[custom_handler_model], action):
|
|
return getattr(self.env[custom_handler_model], action)(*args)
|
|
return getattr(self, action)(*args)
|
|
|
|
def _get_custom_report_function(self, function_name, prefix):
|
|
""" Returns a report function from its name, first checking it to ensure it's private (and raising if it isn't).
|
|
This helper is used by custom report fields containing function names.
|
|
The function will be called on the report's custom handler if it exists, or on the report itself otherwise.
|
|
"""
|
|
self.ensure_one()
|
|
function_name_prefix = f'_report_{prefix}_'
|
|
if not function_name.startswith(function_name_prefix):
|
|
raise UserError(_("Method '%(method_name)s' must start with the '%(prefix)s' prefix.", method_name=function_name, prefix=function_name_prefix))
|
|
|
|
if self.custom_handler_model_id:
|
|
handler = self.env[self.custom_handler_model_name]
|
|
if hasattr(handler, function_name):
|
|
return getattr(handler, function_name)
|
|
|
|
if not hasattr(self, function_name):
|
|
raise UserError(_("Invalid method “%s”", function_name))
|
|
# Call the check method without the private prefix to check for others security risks.
|
|
return getattr(self, function_name)
|
|
|
|
def _get_lines(self, options, all_column_groups_expression_totals=None, warnings=None):
|
|
self.ensure_one()
|
|
|
|
if options['report_id'] != self.id:
|
|
# Should never happen; just there to prevent BIG issues and directly spot them
|
|
raise UserError(_("Inconsistent report_id in options dictionary. Options says %(options_report)s; report is %(report)s.", options_report=options['report_id'], report=self.id))
|
|
|
|
# Necessary to ensure consistency of the data if some of them haven't been written in database yet
|
|
self.env.flush_all()
|
|
|
|
if warnings is not None:
|
|
self._generate_common_warnings(options, warnings)
|
|
|
|
# Merge static and dynamic lines in a common list
|
|
if all_column_groups_expression_totals is None:
|
|
self._init_currency_table(options)
|
|
all_column_groups_expression_totals = self._compute_expression_totals_for_each_column_group(
|
|
self.line_ids.expression_ids,
|
|
options,
|
|
warnings=warnings,
|
|
)
|
|
|
|
dynamic_lines = self._get_dynamic_lines(options, all_column_groups_expression_totals, warnings=warnings)
|
|
|
|
lines = []
|
|
line_cache = {} # {report_line: report line dict}
|
|
hide_if_zero_lines = self.env['account.report.line']
|
|
|
|
# There are two types of lines:
|
|
# - static lines: the ones generated from self.line_ids
|
|
# - dynamic lines: the ones generated from a call to the functions referred to by self.dynamic_lines_generator
|
|
# This loops combines both types of lines together within the lines list
|
|
for line in self.line_ids: # _order ensures the sequence of the lines
|
|
# Inject all the dynamic lines whose sequence is inferior to the next static line to add
|
|
while dynamic_lines and line.sequence > dynamic_lines[0][0]:
|
|
lines.append(dynamic_lines.pop(0)[1])
|
|
parent_generic_id = line_cache[line.parent_id]['id'] if line.parent_id else None # The parent line has necessarily been treated in a previous iteration
|
|
line_dict = self._get_static_line_dict(options, line, all_column_groups_expression_totals, parent_id=parent_generic_id)
|
|
line_cache[line] = line_dict
|
|
|
|
if line.hide_if_zero:
|
|
hide_if_zero_lines += line
|
|
|
|
lines.append(line_dict)
|
|
|
|
for dummy, left_dynamic_line in dynamic_lines:
|
|
lines.append(left_dynamic_line)
|
|
|
|
# Manage growth comparison
|
|
if options.get('column_percent_comparison') == 'growth':
|
|
for line in lines:
|
|
first_value, second_value = line['columns'][0]['no_format'], line['columns'][1]['no_format']
|
|
|
|
green_on_positive = True
|
|
model, line_id = self._get_model_info_from_id(line['id'])
|
|
|
|
if model == 'account.report.line' and line_id:
|
|
report_line = self.env['account.report.line'].browse(line_id)
|
|
compared_expression = report_line.expression_ids.filtered(
|
|
lambda expr: expr.label == line['columns'][0]['expression_label']
|
|
)
|
|
green_on_positive = compared_expression.green_on_positive
|
|
|
|
line['column_percent_comparison_data'] = self._compute_column_percent_comparison_data(
|
|
options, first_value, second_value, green_on_positive=green_on_positive
|
|
)
|
|
# Manage budget comparison
|
|
elif options.get('column_percent_comparison') == 'budget':
|
|
for line in lines:
|
|
self._set_budget_column_comparisons(options, line)
|
|
|
|
# Manage hide_if_zero lines:
|
|
# - If they have column values: hide them if all those values are 0 (or empty)
|
|
# - If they don't: hide them if all their children's column values are 0 (or empty)
|
|
# Also, hide all the children of a hidden line.
|
|
hidden_lines_dict_ids = set()
|
|
for line in hide_if_zero_lines:
|
|
children_to_check = line
|
|
current = line
|
|
while current:
|
|
children_to_check |= current
|
|
current = current.children_ids
|
|
|
|
all_children_zero = True
|
|
hide_candidates = set()
|
|
for child in children_to_check:
|
|
child_line_dict_id = line_cache[child]['id']
|
|
|
|
if child_line_dict_id in hidden_lines_dict_ids:
|
|
continue
|
|
elif all(col.get('is_zero', True) for col in line_cache[child]['columns']):
|
|
hide_candidates.add(child_line_dict_id)
|
|
else:
|
|
all_children_zero = False
|
|
break
|
|
|
|
if all_children_zero:
|
|
hidden_lines_dict_ids |= hide_candidates
|
|
|
|
lines[:] = filter(lambda x: x['id'] not in hidden_lines_dict_ids and x.get('parent_id') not in hidden_lines_dict_ids, lines)
|
|
|
|
# Create the hierarchy of lines if necessary
|
|
if options.get('hierarchy'):
|
|
lines = self._create_hierarchy(lines, options)
|
|
|
|
# Handle totals below sections for static lines
|
|
lines = self._add_totals_below_sections(lines, options)
|
|
|
|
# Unfold lines (static or dynamic) if necessary and add totals below section to dynamic lines
|
|
lines = self._fully_unfold_lines_if_needed(lines, options)
|
|
|
|
if self.custom_handler_model_id:
|
|
lines = self.env[self.custom_handler_model_name]._custom_line_postprocessor(self, options, lines)
|
|
|
|
if warnings is not None:
|
|
custom_handler_name = self.custom_handler_model_name or self.root_report_id.custom_handler_model_name
|
|
if custom_handler_name:
|
|
self.env[custom_handler_name]._customize_warnings(self, options, all_column_groups_expression_totals, warnings)
|
|
|
|
# Format values in columns of lines that will be displayed
|
|
self._format_column_values(options, lines)
|
|
|
|
if options.get('export_mode') == 'print' and options.get('hide_0_lines'):
|
|
lines = self._filter_out_0_lines(lines)
|
|
|
|
return lines
|
|
|
|
@api.model
|
|
def format_column_values(self, options, lines):
|
|
self._format_column_values(options, lines, force_format=True)
|
|
|
|
return lines
|
|
|
|
def _format_column_values(self, options, line_dict_list, force_format=False):
|
|
for line_dict in line_dict_list:
|
|
for column_dict in line_dict['columns']:
|
|
if 'name' in column_dict and not force_format:
|
|
# Columns which have already received a name are assumed to be already formatted; nothing needs to be done for them.
|
|
# This gives additional flexibility to custom reports, if needed.
|
|
continue
|
|
|
|
if not column_dict:
|
|
continue
|
|
elif column_dict.get('is_zero') and column_dict.get('blank_if_zero'):
|
|
rslt = ''
|
|
else:
|
|
rslt = self.format_value(
|
|
options,
|
|
column_dict.get('no_format'),
|
|
column_dict.get('figure_type'),
|
|
format_params=column_dict.get('format_params'),
|
|
)
|
|
|
|
column_dict['name'] = rslt
|
|
|
|
# Handle the total in case of an horizontal group when there is no comparison and only one level of horizontal group
|
|
if options.get('show_horizontal_group_total'):
|
|
# In case the line has no formula
|
|
if all(column['no_format'] is None for column in line_dict['columns']):
|
|
continue
|
|
# In case total below section, some line don't have the value displayed
|
|
if self.env.company.totals_below_sections and not options.get('ignore_totals_below_sections') and line_dict['unfolded']:
|
|
continue
|
|
|
|
figure_type_is_valid = all(column['figure_type'] in {'float', 'integer', 'monetary'} for column in line_dict['columns'])
|
|
total_value = sum(column["no_format"] for column in line_dict['columns']) if figure_type_is_valid else None
|
|
line_dict['horizontal_group_total_data'] = {
|
|
'name': self.format_value(
|
|
options,
|
|
total_value,
|
|
line_dict['columns'][0]['figure_type'],
|
|
format_params=line_dict['columns'][0]['format_params'],
|
|
),
|
|
'no_format': total_value,
|
|
}
|
|
|
|
def _generate_common_warnings(self, options, warnings):
|
|
# Display a warning if we're displaying only the data of the current company, but it's also part of a tax unit
|
|
if options.get('available_tax_units') and options['tax_unit'] == 'company_only':
|
|
warnings['fusion_accounting.common_warning_tax_unit'] = {}
|
|
|
|
report_company_ids = self.get_report_company_ids(options)
|
|
# The _accessible_branches function will return the accessible branches from the ones that are already selected,
|
|
# and the report_company_ids function will return the current company and its branches (that are selected) with the same VAT
|
|
# or tax unit. Therefore, we will display the warning only when the selected companies do not have the same VAT
|
|
# and in the context of branches.
|
|
if self.filter_multi_company == 'tax_units' and any(accessible_branch.id not in report_company_ids for accessible_branch in self.env.company._accessible_branches()):
|
|
warnings['fusion_accounting.tax_report_warning_tax_id_selected_companies'] = {'alert_type': 'warning'}
|
|
|
|
# Check whether there are unposted entries for the selected period or not (if the report allows it)
|
|
if options.get('date') and options.get('all_entries') is not None:
|
|
if self.env['account.move'].search_count(
|
|
[('state', '=', 'draft'), ('date', '<=', options['date']['date_to'])],
|
|
limit=1,
|
|
):
|
|
warnings['fusion_accounting.common_warning_draft_in_period'] = {}
|
|
|
|
def _fully_unfold_lines_if_needed(self, lines, options):
|
|
def line_need_expansion(line_dict):
|
|
return line_dict.get('unfolded') and line_dict.get('expand_function')
|
|
|
|
custom_unfold_all_batch_data = None
|
|
|
|
# If it's possible to batch unfold and we're unfolding all lines, compute the batch, so that individual expansions are more efficient
|
|
if options['unfold_all'] and self.custom_handler_model_id:
|
|
lines_to_expand_by_function = {}
|
|
for line_dict in lines:
|
|
if line_need_expansion(line_dict):
|
|
lines_to_expand_by_function.setdefault(line_dict['expand_function'], []).append(line_dict)
|
|
|
|
custom_unfold_all_batch_data = self.env[self.custom_handler_model_name]._custom_unfold_all_batch_data_generator(self, options, lines_to_expand_by_function)
|
|
|
|
i = 0
|
|
while i < len(lines):
|
|
# We iterate in such a way that if the lines added by an expansion need expansion, they will get it as well
|
|
line_dict = lines[i]
|
|
if line_need_expansion(line_dict):
|
|
groupby = line_dict.get('groupby')
|
|
progress = line_dict.get('progress')
|
|
to_insert = self._expand_unfoldable_line(
|
|
line_dict['expand_function'], line_dict['id'], groupby, options, progress, 0, line_dict.get('horizontal_split_side'),
|
|
unfold_all_batch_data=custom_unfold_all_batch_data,
|
|
)
|
|
lines = lines[:i+1] + to_insert + lines[i+1:]
|
|
i += 1
|
|
|
|
return lines
|
|
|
|
def _generate_total_below_section_line(self, section_line_dict):
|
|
return {
|
|
**section_line_dict,
|
|
'id': self._get_generic_line_id(None, None, parent_line_id=section_line_dict['id'], markup='total'),
|
|
'level': section_line_dict['level'] if section_line_dict['level'] != 0 else 1, # Total line should not be level 0
|
|
'name': _("Total %s", section_line_dict['name']),
|
|
'parent_id': section_line_dict['id'],
|
|
'unfoldable': False,
|
|
'unfolded': False,
|
|
'caret_options': None,
|
|
'action_id': None,
|
|
'page_break': False, # If the section's line possesses a page break, we don't want the total to have it.
|
|
}
|
|
|
|
def _get_static_line_dict(self, options, line, all_column_groups_expression_totals, parent_id=None):
|
|
line_id = self._get_generic_line_id('account.report.line', line.id, parent_line_id=parent_id)
|
|
columns = self._build_static_line_columns(line, options, all_column_groups_expression_totals)
|
|
has_children = (any(col['has_sublines'] for col in columns) or bool(line.children_ids))
|
|
groupby = line._get_groupby(options)
|
|
|
|
rslt = {
|
|
'id': line_id,
|
|
'name': line.name,
|
|
'groupby': groupby,
|
|
'unfoldable': line.foldable and has_children,
|
|
'unfolded': bool((not line.foldable and (line.children_ids or groupby)) or line_id in options['unfolded_lines']) or (has_children and options['unfold_all']),
|
|
'columns': columns,
|
|
'level': line.hierarchy_level,
|
|
'page_break': line.print_on_new_page,
|
|
'action_id': line.action_id.id,
|
|
'expand_function': groupby and '_report_expand_unfoldable_line_with_groupby' or None,
|
|
}
|
|
|
|
if line.horizontal_split_side:
|
|
rslt['horizontal_split_side'] = line.horizontal_split_side
|
|
|
|
if parent_id:
|
|
rslt['parent_id'] = parent_id
|
|
|
|
if options['export_mode'] == 'file':
|
|
rslt['code'] = line.code
|
|
|
|
if options['show_debug_column']:
|
|
first_group_key = list(options['column_groups'].keys())[0]
|
|
column_group_totals = all_column_groups_expression_totals[first_group_key]
|
|
# Only consider the first column group, as show_debug_column is only true if there is but one.
|
|
|
|
engine_selection_labels = dict(self.env['account.report.expression']._fields['engine']._description_selection(self.env))
|
|
expressions_detail = defaultdict(lambda: [])
|
|
col_expression_to_figure_type = {
|
|
column.get('expression_label'): column.get('figure_type') for column in options['columns']
|
|
}
|
|
for expression in line.expression_ids.filtered(lambda x: not x.label.startswith('_default')):
|
|
engine_label = engine_selection_labels[expression.engine]
|
|
figure_type = expression.figure_type or col_expression_to_figure_type.get(expression.label) or 'none'
|
|
expressions_detail[engine_label].append((
|
|
expression.label,
|
|
{'formula': expression.formula, 'subformula': expression.subformula, 'value': self.format_value(options, column_group_totals[expression]['value'], figure_type)}
|
|
))
|
|
|
|
# Sort results so that they can be rendered nicely in the UI
|
|
for details in expressions_detail.values():
|
|
details.sort(key=lambda x: x[0])
|
|
sorted_expressions_detail = sorted(expressions_detail.items(), key=lambda x: x[0])
|
|
|
|
if sorted_expressions_detail:
|
|
try:
|
|
rslt['debug_popup_data'] = json.dumps({'expressions_detail': sorted_expressions_detail})
|
|
except TypeError:
|
|
raise UserError(_(
|
|
'Invalid subformula in expression "%(expression)s" of line "%(line)s": %(subformula)s',
|
|
expression=expression.label,
|
|
line=expression.report_line_id.name,
|
|
subformula=expression.subformula,
|
|
))
|
|
return rslt
|
|
|
|
@api.model
|
|
def _build_static_line_columns(self, line, options, all_column_groups_expression_totals, groupby_model=None):
|
|
line_expressions_map = {expr.label: expr for expr in line.expression_ids}
|
|
columns = []
|
|
for column_data in options['columns']:
|
|
col_group_key = column_data['column_group_key']
|
|
current_group_expression_totals = all_column_groups_expression_totals[col_group_key]
|
|
target_line_res_dict = {expr.label: current_group_expression_totals[expr] for expr in line.expression_ids if not expr.label.startswith('_default')}
|
|
|
|
column_expr_label = column_data['expression_label']
|
|
column_res_dict = target_line_res_dict.get(column_expr_label, {})
|
|
column_value = column_res_dict.get('value')
|
|
column_has_sublines = column_res_dict.get('has_sublines', False)
|
|
column_expression = line_expressions_map.get(column_expr_label, self.env['account.report.expression'])
|
|
figure_type = column_expression.figure_type or column_data['figure_type']
|
|
|
|
# Handle info popup
|
|
info_popup_data = {}
|
|
|
|
# Check carryover
|
|
carryover_expr_label = '_carryover_%s' % column_expr_label
|
|
carryover_value = target_line_res_dict.get(carryover_expr_label, {}).get('value', 0)
|
|
if self.env.company.currency_id.compare_amounts(0, carryover_value) != 0:
|
|
info_popup_data['carryover'] = self._format_value(options, carryover_value, 'monetary')
|
|
|
|
carryover_expression = line_expressions_map[carryover_expr_label]
|
|
if carryover_expression.carryover_target:
|
|
info_popup_data['carryover_target'] = carryover_expression._get_carryover_target_expression(options).report_line_name
|
|
# If it's not set, it means the carryover needs to target the same expression
|
|
|
|
applied_carryover_value = target_line_res_dict.get('_applied_carryover_%s' % column_expr_label, {}).get('value', 0)
|
|
if self.env.company.currency_id.compare_amounts(0, applied_carryover_value) != 0:
|
|
info_popup_data['applied_carryover'] = self._format_value(options, applied_carryover_value, 'monetary')
|
|
info_popup_data['allow_carryover_audit'] = self.env.user.has_group('base.group_no_one')
|
|
info_popup_data['expression_id'] = line_expressions_map['_applied_carryover_%s' % column_expr_label]['id']
|
|
info_popup_data['column_group_key'] = col_group_key
|
|
|
|
# Handle manual edition popup
|
|
edit_popup_data = {}
|
|
formatter_params = {}
|
|
if column_expression.engine == 'external' and column_expression.subformula \
|
|
and len(options['companies']) == 1 \
|
|
and (not options['available_vat_fiscal_positions'] or options['fiscal_position'] != 'all'):
|
|
|
|
# Compute rounding for manual values
|
|
rounding = None
|
|
if figure_type == 'integer':
|
|
rounding = 0
|
|
else:
|
|
rounding_opt_match = re.search(r"\Wrounding\W*=\W*(?P<rounding>\d+)", column_expression.subformula)
|
|
if rounding_opt_match:
|
|
rounding = int(rounding_opt_match.group('rounding'))
|
|
elif figure_type == 'monetary':
|
|
rounding = self.env.company.currency_id.decimal_places
|
|
|
|
if 'editable' in column_expression.subformula:
|
|
edit_popup_data = {
|
|
'column_group_key': col_group_key,
|
|
'target_expression_id': column_expression.id,
|
|
'rounding': rounding,
|
|
'figure_type': figure_type,
|
|
'column_value': column_value,
|
|
}
|
|
|
|
formatter_params['digits'] = rounding
|
|
|
|
# Handle editable financial budgets
|
|
editable_budget = groupby_model == 'account.account' and options['column_groups'][col_group_key]['forced_options'].get('compute_budget')
|
|
if editable_budget and self.env.user.has_group('account.group_account_manager'):
|
|
edit_popup_data = {
|
|
'column_group_key': col_group_key,
|
|
'target_expression_id': column_expression.id,
|
|
'rounding': self.env.company.currency_id.decimal_places,
|
|
'figure_type': 'monetary',
|
|
'column_value': column_value,
|
|
}
|
|
|
|
# Build result
|
|
if column_value is not None: #In case column value is zero, we still want to go through the condition
|
|
foreign_currency_id = target_line_res_dict.get(f'_currency_{column_expr_label}', {}).get('value')
|
|
if foreign_currency_id:
|
|
formatter_params['currency'] = self.env['res.currency'].browse(foreign_currency_id)
|
|
|
|
column_data = self._build_column_dict(
|
|
column_value,
|
|
column_data,
|
|
options=options,
|
|
column_expression=column_expression if column_expression else None,
|
|
has_sublines=column_has_sublines,
|
|
report_line_id=line.id,
|
|
**formatter_params,
|
|
)
|
|
|
|
if info_popup_data:
|
|
column_data['info_popup_data'] = json.dumps(info_popup_data)
|
|
|
|
if edit_popup_data:
|
|
column_data['edit_popup_data'] = json.dumps(edit_popup_data)
|
|
|
|
columns.append(column_data)
|
|
|
|
return columns
|
|
|
|
def _build_column_dict(
|
|
self, col_value, col_data,
|
|
options=None, currency=False, digits=1,
|
|
column_expression=None, has_sublines=False,
|
|
report_line_id=None,
|
|
):
|
|
# Empty column
|
|
if col_value is None and col_data is None:
|
|
return {}
|
|
|
|
col_data = col_data or {}
|
|
column_expression = column_expression or self.env['account.report.expression']
|
|
options = options or {}
|
|
|
|
blank_if_zero = column_expression.blank_if_zero or col_data.get('blank_if_zero', False)
|
|
figure_type = column_expression.figure_type or col_data.get('figure_type', 'string')
|
|
|
|
format_params = {}
|
|
if figure_type == 'monetary' and currency:
|
|
format_params['currency_id'] = currency.id
|
|
elif figure_type in ('float', 'percentage'):
|
|
format_params['digits'] = digits
|
|
|
|
col_group_key = col_data.get('column_group_key')
|
|
|
|
return {
|
|
'auditable': col_value is not None
|
|
and column_expression.auditable
|
|
and not options['column_groups'][col_group_key]['forced_options'].get('compute_budget'),
|
|
'blank_if_zero': blank_if_zero,
|
|
'column_group_key': col_group_key,
|
|
'currency': currency,
|
|
'currency_symbol': (currency or self.env.company.currency_id).symbol if options.get('multi_currency') else None,
|
|
'digits': digits,
|
|
'expression_label': col_data.get('expression_label'),
|
|
'figure_type': figure_type,
|
|
'green_on_positive': column_expression.green_on_positive,
|
|
'has_sublines': has_sublines,
|
|
'is_zero': col_value is None or (
|
|
isinstance(col_value, (int, float))
|
|
and figure_type in NUMBER_FIGURE_TYPES
|
|
and self._is_value_zero(col_value, figure_type, format_params)
|
|
),
|
|
'no_format': col_value,
|
|
'format_params': format_params,
|
|
'report_line_id': report_line_id,
|
|
'sortable': col_data.get('sortable', False),
|
|
'comparison_mode': col_data.get('comparison_mode'),
|
|
}
|
|
|
|
def _get_dynamic_lines(self, options, all_column_groups_expression_totals, warnings=None):
|
|
if self.custom_handler_model_id:
|
|
rslt = self.env[self.custom_handler_model_name]._dynamic_lines_generator(self, options, all_column_groups_expression_totals, warnings=warnings)
|
|
self._apply_integer_rounding_to_dynamic_lines(options, (line for _sequence, line in rslt))
|
|
return rslt
|
|
return []
|
|
|
|
def _apply_integer_rounding_to_dynamic_lines(self, options, dynamic_lines):
|
|
if options.get('integer_rounding_enabled'):
|
|
for line in dynamic_lines:
|
|
for column_dict in line.get('columns', []):
|
|
if 'name' not in column_dict and column_dict.get('figure_type') == 'monetary' and column_dict.get('no_format'):
|
|
# If 'name' is already in it, no need to round the amount ; it is forced by the custom report already
|
|
column_dict['no_format'] = float_round(
|
|
column_dict['no_format'],
|
|
precision_digits=0,
|
|
rounding_method=options['integer_rounding'],
|
|
)
|
|
|
|
def _compute_expression_totals_for_each_column_group(self, expressions, options,
|
|
groupby_to_expand=None, forced_all_column_groups_expression_totals=None, col_groups_restrict=None, offset=0, limit=None, include_default_vals=False, warnings=None):
|
|
"""
|
|
Main computation function for static lines.
|
|
|
|
:param expressions: The account.report.expression objects to evaluate.
|
|
|
|
:param options: The options dict for this report, obtained from.get_options({}).
|
|
|
|
:param groupby_to_expand: The full groupby string for the grouping we want to evaluate. If None, the aggregated value will be computed.
|
|
For example, when evaluating a group by partner_id, which further will be divided in sub-groups by account_id,
|
|
then id, the full groupby string will be: 'partner_id, account_id, id'.
|
|
|
|
:param forced_all_column_groups_expression_totals: The expression totals already computed for this report, to which we will add the
|
|
new totals we compute for expressions (or update the existing ones if some
|
|
expressions are already in forced_all_column_groups_expression_totals). This is
|
|
a dict in the same format as returned by this function.
|
|
This parameter is for example used when adding manual values, where only
|
|
the expressions possibly depending on the new manual value
|
|
need to be updated, while we want to keep all the other values as-is.
|
|
|
|
:param col_groups_restrict: List of column group keys of the groups to compute. Other column groups will be ignored, and will
|
|
not be added to the result of this function (they can still be provided beforehand through
|
|
forced_all_column_groups_expression_totals). If not provided, all colum groups will be computed.
|
|
|
|
:param offset: The SQL offset to use when computing the result of these expressions. Used if self.load_more_limit is set, to handle
|
|
the load more feature.
|
|
|
|
:param limit: The SQL limit to apply when computing these expressions' result. Used if self.load_more_limit is set, to handle
|
|
the load more feature.
|
|
|
|
:return: dict(column_group_key, expressions_totals), where:
|
|
- column group key is string identifying each column group in a unique way ; as in options['column_groups']
|
|
- expressions_totals is a dict in the format returned by _compute_expression_totals_for_single_column_group
|
|
"""
|
|
|
|
def add_expressions_to_groups(expressions_to_add, grouped_formulas, force_date_scope=None):
|
|
""" Groups the expressions that should be computed together.
|
|
"""
|
|
for expression in expressions_to_add:
|
|
engine = expression.engine
|
|
|
|
if engine not in grouped_formulas:
|
|
grouped_formulas[engine] = {}
|
|
|
|
date_scope = force_date_scope or self._standardize_date_scope_for_date_range(expression.date_scope)
|
|
groupby_data = expression.report_line_id._parse_groupby(options, groupby_to_expand=groupby_to_expand)
|
|
|
|
next_groupby = groupby_data['next_groupby'] if engine not in NO_NEXT_GROUPBY_ENGINES else None
|
|
grouping_key = (date_scope, groupby_data['current_groupby'], next_groupby)
|
|
|
|
if grouping_key not in grouped_formulas[engine]:
|
|
grouped_formulas[engine][grouping_key] = {}
|
|
|
|
formula = expression.formula
|
|
|
|
if expression.engine == 'aggregation' and expression.formula == 'sum_children':
|
|
formula = ' + '.join(
|
|
f'_expression:{child_expr.id}'
|
|
for child_expr in expression.report_line_id.children_ids.expression_ids.filtered(lambda e: e.label == expression.label)
|
|
)
|
|
|
|
if formula not in grouped_formulas[engine][grouping_key]:
|
|
grouped_formulas[engine][grouping_key][formula] = expression
|
|
else:
|
|
grouped_formulas[engine][grouping_key][formula] |= expression
|
|
|
|
if groupby_to_expand and any(not expression.report_line_id._get_groupby(options) for expression in expressions):
|
|
raise UserError(_("Trying to expand groupby results on lines without a groupby value."))
|
|
|
|
# Group formulas for batching (when possible)
|
|
grouped_formulas = {}
|
|
if expressions and not include_default_vals:
|
|
expressions = expressions.filtered(lambda x: not x.label.startswith('_default'))
|
|
for expression in expressions:
|
|
add_expressions_to_groups(expression, grouped_formulas)
|
|
|
|
if expression.engine == 'aggregation' and expression.subformula == 'cross_report':
|
|
# Always expand aggregation expressions, in case their subexpressions are not in expressions parameter
|
|
# (this can happen in cross report, or when auditing an individual aggregation expression)
|
|
expanded_cross = expression._expand_aggregations()
|
|
forced_date_scope = self._standardize_date_scope_for_date_range(expression.date_scope)
|
|
add_expressions_to_groups(expanded_cross, grouped_formulas, force_date_scope=forced_date_scope)
|
|
|
|
# Treat each formula batch for each column group
|
|
all_column_groups_expression_totals = {}
|
|
for group_key, group_options in self._split_options_per_column_group(options).items():
|
|
if forced_all_column_groups_expression_totals:
|
|
forced_column_group_totals = forced_all_column_groups_expression_totals.get(group_key, None)
|
|
else:
|
|
forced_column_group_totals = None
|
|
|
|
if not col_groups_restrict or group_key in col_groups_restrict:
|
|
current_group_expression_totals = self._compute_expression_totals_for_single_column_group(
|
|
group_options,
|
|
grouped_formulas,
|
|
forced_column_group_expression_totals=forced_column_group_totals,
|
|
offset=offset,
|
|
limit=limit,
|
|
warnings=warnings,
|
|
)
|
|
else:
|
|
current_group_expression_totals = forced_column_group_totals
|
|
|
|
all_column_groups_expression_totals[group_key] = current_group_expression_totals
|
|
|
|
return all_column_groups_expression_totals
|
|
|
|
def _standardize_date_scope_for_date_range(self, date_scope):
|
|
""" Depending on the fact the report accepts date ranges or not, different date scopes might mean the same thing.
|
|
This function is used so that, in those cases, only one of these date_scopes' values is used, to avoid useless creation
|
|
of multiple computation batches and improve the overall performance as much as possible.
|
|
"""
|
|
if not self.filter_date_range and date_scope == 'strict_range':
|
|
return 'from_beginning'
|
|
else:
|
|
return date_scope
|
|
|
|
def _split_options_per_column_group(self, options):
|
|
""" Get a specific option dict per column group, each enforcing the comparison and horizontal grouping associated
|
|
with the column group. Each of these options dict will contain a new key 'owner_column_group', with the column group key of the
|
|
group it was generated for.
|
|
|
|
:param options: The report options upon which the returned options be be based.
|
|
|
|
:return: A dict(column_group_key, options_dict), where column_group_key is the string identifying each column group (the keys
|
|
of options['column_groups'], and options_dict the generated options for this group.
|
|
"""
|
|
options_per_group = {}
|
|
for group_key in options['column_groups']:
|
|
group_options = self._get_column_group_options(options, group_key)
|
|
options_per_group[group_key] = group_options
|
|
|
|
return options_per_group
|
|
|
|
def _get_column_group_options(self, options, group_key):
|
|
column_group = options['column_groups'][group_key]
|
|
return {
|
|
**options,
|
|
**column_group['forced_options'],
|
|
'forced_domain': options.get('forced_domain', []) + column_group['forced_domain'] + column_group['forced_options'].get('forced_domain', []),
|
|
'owner_column_group': group_key,
|
|
}
|
|
|
|
def _compute_expression_totals_for_single_column_group(self, column_group_options, grouped_formulas, forced_column_group_expression_totals=None, offset=0, limit=None, warnings=None):
|
|
""" Evaluates expressions for a single column group.
|
|
|
|
:param column_group_options: The options dict obtained from _split_options_per_column_group() for the column group to evaluate.
|
|
|
|
:param grouped_formulas: A dict(engine, formula_dict), where:
|
|
- engine is a string identifying a report engine, in the same format as in account.report.expression's engine
|
|
field's technical labels.
|
|
- formula_dict is a dict in the same format as _compute_formula_batch's formulas_dict parameter,
|
|
containing only aggregation formulas.
|
|
|
|
:param forced_column_group_expression_totals: The expression totals previously computed, in the same format as this function's result.
|
|
If provided, the result of this function will be an updated version of this parameter,
|
|
recomputing the expressions in grouped_fomulas.
|
|
|
|
:param offset: The SQL offset to use when computing the result of these expressions. Used if self.load_more_limit is set, to handle
|
|
the load more feature.
|
|
|
|
:param limit: The SQL limit to apply when computing these expressions' result. Used if self.load_more_limit is set, to handle
|
|
the load more feature.
|
|
|
|
:return: A dict(expression, {'value': value, 'has_sublines': has_sublines}), where:
|
|
- expression is one of the account.report.expressions that got evaluated
|
|
|
|
- value is the result of that evaluation. Two cases are possible:
|
|
- if we're evaluating a groupby: value will then be a in the form [(groupby_key, group_val)], where
|
|
- groupby_key is the key used in the SQL GROUP BY clause to generate this result
|
|
- group_val: The result computed by the engine for this group. Typically a float.
|
|
|
|
- else: value will directly be the result computed for this expression
|
|
|
|
- has_sublines: [optional key, will default to False if absent]
|
|
Whether or not this result corresponds to 1 or more subelements in the database (typically move lines).
|
|
This is used to know whether an unfoldable line has results to unfold in the UI.
|
|
"""
|
|
def inject_formula_results(formula_results, column_group_expression_totals, cross_report_expression_totals=None):
|
|
for (_key, expressions), result in formula_results.items():
|
|
for expression in expressions:
|
|
subformula_error_format = _(
|
|
'Invalid subformula in expression "%(expression)s" of line "%(line)s": %(subformula)s',
|
|
expression=expression.label,
|
|
line=expression.report_line_id.name,
|
|
subformula=expression.subformula,
|
|
)
|
|
if expression.engine not in ('aggregation', 'external') and expression.subformula:
|
|
# aggregation subformulas behave differently (cross_report is markup ; if_below, if_above and force_between need evaluation)
|
|
# They are directly handled in aggregation engine
|
|
result_value_key = expression.subformula
|
|
else:
|
|
result_value_key = 'result'
|
|
|
|
# The expression might be signed, so we can't just access the dict key, and directly evaluate it instead.
|
|
|
|
if isinstance(result, list):
|
|
# Happens when expanding a groupby line, to compute its children.
|
|
# We then want to keep a list(grouping key, total) as the final result of each total
|
|
expression_value = []
|
|
expression_has_sublines = False
|
|
for key, result_dict in result:
|
|
try:
|
|
expression_value.append((key, safe_eval(result_value_key, result_dict)))
|
|
except (ValueError, SyntaxError):
|
|
raise UserError(subformula_error_format)
|
|
expression_has_sublines = expression_has_sublines or result_dict.get('has_sublines')
|
|
else:
|
|
# For non-groupby lines, we directly set the total value for the line.
|
|
try:
|
|
expression_value = safe_eval(result_value_key, result)
|
|
except (ValueError, SyntaxError):
|
|
raise UserError(subformula_error_format)
|
|
expression_has_sublines = result.get('has_sublines')
|
|
|
|
if column_group_options.get('integer_rounding_enabled'):
|
|
in_monetary_column = any(
|
|
col['expression_label'] == expression.label
|
|
for col in column_group_options['columns']
|
|
if col['figure_type'] == 'monetary'
|
|
)
|
|
|
|
if (in_monetary_column and not expression.figure_type) or expression.figure_type == 'monetary':
|
|
expression_value = float_round(expression_value, precision_digits=0, rounding_method=column_group_options['integer_rounding'])
|
|
|
|
expression_result = {
|
|
'value': expression_value,
|
|
'has_sublines': expression_has_sublines,
|
|
}
|
|
|
|
if expression.report_line_id.report_id == self:
|
|
if expression in column_group_expression_totals:
|
|
# This can happen because of a cross report aggregation referencing an expression of its own report,
|
|
# but forcing a different date_scope onto it. This case is not supported for now ; splitting the aggregation can be
|
|
# used as a workaround.
|
|
raise UserError(_(
|
|
"Expression labelled '%(label)s' of line '%(line)s' is being overwritten when computing the current report. "
|
|
"Make sure the cross-report aggregations of this report only reference terms belonging to other reports.",
|
|
label=expression.label, line=expression.report_line_id.name
|
|
))
|
|
column_group_expression_totals[expression] = expression_result
|
|
elif cross_report_expression_totals is not None:
|
|
# Entering this else means this expression needs to be evaluated because of a cross_report aggregation
|
|
cross_report_expression_totals[expression] = expression_result
|
|
|
|
# Batch each engine that can be
|
|
column_group_expression_totals = dict(forced_column_group_expression_totals) if forced_column_group_expression_totals else {}
|
|
cross_report_expr_totals_by_scope = {}
|
|
batchable_engines = [
|
|
selection_val[0]
|
|
for selection_val in self.env['account.report.expression']._fields['engine'].selection
|
|
if selection_val[0] != 'aggregation'
|
|
]
|
|
for engine in batchable_engines:
|
|
for (date_scope, current_groupby, next_groupby), formulas_dict in grouped_formulas.get(engine, {}).items():
|
|
formula_results = self._compute_formula_batch(column_group_options, engine, date_scope, formulas_dict, current_groupby, next_groupby,
|
|
offset=offset, limit=limit, warnings=warnings)
|
|
inject_formula_results(
|
|
formula_results,
|
|
column_group_expression_totals,
|
|
cross_report_expression_totals=cross_report_expr_totals_by_scope.setdefault(date_scope, {})
|
|
)
|
|
|
|
# Now that everything else has been computed, resolve aggregation expressions
|
|
# (they can't be treated as the other engines, as if we batch them per date_scope, we'll not be able
|
|
# to compute expressions depending on other expressions with a different date scope).
|
|
aggregation_formulas_dict = {}
|
|
for (date_scope, _current_groupby, _next_groupby), formulas_dict in grouped_formulas.get('aggregation', {}).items():
|
|
for formula, expressions in formulas_dict.items():
|
|
for expression in expressions:
|
|
# group_by are ignored by this engine, so we merge every grouped entry into a common dict
|
|
forced_date_scope = date_scope if expression.subformula == 'cross_report' or expression.report_line_id.report_id != self else None
|
|
aggreation_formula_dict_key = (formula, forced_date_scope)
|
|
aggregation_formulas_dict.setdefault(aggreation_formula_dict_key, self.env['account.report.expression'])
|
|
aggregation_formulas_dict[aggreation_formula_dict_key] |= expression
|
|
|
|
if aggregation_formulas_dict:
|
|
aggregation_formula_results = self._compute_totals_no_batch_aggregation(column_group_options, aggregation_formulas_dict, column_group_expression_totals, cross_report_expr_totals_by_scope)
|
|
inject_formula_results(aggregation_formula_results, column_group_expression_totals)
|
|
|
|
return column_group_expression_totals
|
|
|
|
def _compute_totals_no_batch_aggregation(self, column_group_options, formulas_dict, other_current_report_expr_totals, other_cross_report_expr_totals_by_scope):
|
|
""" Computes expression totals for 'aggregation' engine, after all other engines have been evaluated.
|
|
|
|
:param column_group_options: The options for the column group being evaluated, as obtained from _split_options_per_column_group.
|
|
|
|
:param formulas_dict: A dict {(formula, forced_date_scope): expressions}, containing only aggregation formulas.
|
|
forced_date_scope will only be set in case of cross_report expressions. Else, it will be None
|
|
|
|
:param other_current_report_expr_totals: The expressions_totals obtained after computing all non-aggregation engines, for the expressions
|
|
belonging directly to self (so, not the ones referenced by a cross_report aggreation).
|
|
This is a dict in the same format as _compute_expression_totals_for_single_column_group's result
|
|
(the only difference being it does not contain any aggregation expression yet).
|
|
|
|
:param other_cross_report_expr_totals: A dict(forced_date_scope, expression_totals), where expression_totals is in the same form as
|
|
_compute_expression_totals_for_single_column_group's result. This parameter contains the results
|
|
of the non-aggregation expressions used by cross_report expressions ; they all belong to different
|
|
reports than self. The forced_date_scope corresponds to the original date_scope set on the
|
|
cross_report expression referencing them. The same expressions can be referenced multiple times
|
|
under different date scopes.
|
|
|
|
:return : A dict((formula, expressions), result), where result is in the form {'result': numeric_value}
|
|
"""
|
|
def _resolve_subformula_on_dict(result, line_codes_expression_map, subformula):
|
|
split_subformula = subformula.split('.')
|
|
if len(split_subformula) > 1:
|
|
line_code, expression_label = split_subformula
|
|
return result[line_codes_expression_map[line_code][expression_label]]
|
|
|
|
if subformula.startswith('_expression:'):
|
|
expression_id = int(subformula.split(':')[1])
|
|
return result[expression_id]
|
|
|
|
# Wrong subformula; the KeyError is caught in the function below
|
|
raise KeyError()
|
|
|
|
def _check_is_float(to_test):
|
|
try:
|
|
float(to_test)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
def add_expression_to_map(expression, expression_res, figure_types_cache, current_report_eval_dict, current_report_codes_map, other_reports_eval_dict, other_reports_codes_map, cross_report=False):
|
|
"""
|
|
Process an expression and its result, updating various dictionaries with relevant information.
|
|
Parameters:
|
|
- expression (object): The expression object to process.
|
|
- expression_res (dict): The result of the expression.
|
|
- figure_types_cache (dict): {report : {label: figure_type}}.
|
|
- current_report_eval_dict (dict): {expression_id: value}.
|
|
- current_report_codes_map (dict): {line_code: {expression_label: expression_id}}.
|
|
- other_reports_eval_dict (dict): {forced_date_scope: {expression_id: value}}.
|
|
- other_reports_codes_map (dict): {forced_date_scope: {line_code: {expression_label: expression_id}}}.
|
|
- cross_report: A boolean to know if we are processsing cross_report expression.
|
|
"""
|
|
|
|
expr_report = expression.report_line_id.report_id
|
|
report_default_figure_types = figure_types_cache.setdefault(expr_report, {})
|
|
expression_label = report_default_figure_types.get(expression.label, '_not_in_cache')
|
|
if expression_label == '_not_in_cache':
|
|
report_default_figure_types[expression.label] = expr_report.column_ids.filtered(
|
|
lambda x: x.expression_label == expression.label).figure_type
|
|
|
|
default_figure_type = figure_types_cache[expr_report][expression.label]
|
|
figure_type = expression.figure_type or default_figure_type
|
|
value = expression_res['value']
|
|
if figure_type == 'monetary' and value:
|
|
value = self.env.company.currency_id.round(value)
|
|
|
|
if cross_report:
|
|
other_reports_eval_dict.setdefault(forced_date_scope, {})[expression.id] = value
|
|
else:
|
|
current_report_eval_dict[expression.id] = value
|
|
|
|
current_report_eval_dict = {} # {expression_id: value}
|
|
other_reports_eval_dict = {} # {forced_date_scope: {expression_id: value}}
|
|
current_report_codes_map = {} # {line_code: {expression_label: expression_id}}
|
|
other_reports_codes_map = {} # {forced_date_scope: {line_code: {expression_label: expression_id}}}
|
|
|
|
figure_types_cache = {} # {report : {label: figure_type}}
|
|
for expression, expression_res in other_current_report_expr_totals.items():
|
|
add_expression_to_map(expression, expression_res, figure_types_cache, current_report_eval_dict, current_report_codes_map, other_reports_eval_dict, other_reports_codes_map)
|
|
if expression.report_line_id.code:
|
|
current_report_codes_map.setdefault(expression.report_line_id.code, {})[expression.label] = expression.id
|
|
|
|
for forced_date_scope, scope_expr_totals in other_cross_report_expr_totals_by_scope.items():
|
|
for expression, expression_res in scope_expr_totals.items():
|
|
add_expression_to_map(expression, expression_res, figure_types_cache, current_report_eval_dict, current_report_codes_map, other_reports_eval_dict, other_reports_codes_map, True)
|
|
if expression.report_line_id.code:
|
|
other_reports_codes_map.setdefault(forced_date_scope, {}).setdefault(expression.report_line_id.code, {})[expression.label] = expression.id
|
|
|
|
# Complete current_report_eval_dict with the formulas of uncomputed aggregation lines
|
|
aggregations_terms_to_evaluate = set() # Those terms are part of the formulas to evaluate; we know they will get a value eventually
|
|
for (formula, forced_date_scope), expressions in formulas_dict.items():
|
|
for expression in expressions:
|
|
aggregations_terms_to_evaluate.add(f"_expression:{expression.id}") # In case it needs to be called by sum_children
|
|
|
|
if expression.report_line_id.code:
|
|
if expression.report_line_id.report_id == self:
|
|
current_report_codes_map.setdefault(expression.report_line_id.code, {})[expression.label] = expression.id
|
|
else:
|
|
other_reports_codes_map.setdefault(forced_date_scope, {}).setdefault(expression.report_line_id.code, {})[expression.label] = expression.id
|
|
|
|
aggregations_terms_to_evaluate.add(f"{expression.report_line_id.code}.{expression.label}")
|
|
|
|
if not expression.subformula:
|
|
# Expressions with bounds cannot be replaced by their formula in formulas calling them (otherwize, bounds would be ignored).
|
|
# Same goes for cross_report, otherwise the forced_date_scope will be ignored, leading to an impossibility to get evaluate the expression.
|
|
if expression.report_line_id.report_id == self:
|
|
eval_dict = current_report_eval_dict
|
|
else:
|
|
eval_dict = other_reports_eval_dict.setdefault(forced_date_scope, {})
|
|
|
|
eval_dict[expression.id] = formula
|
|
|
|
rslt = {}
|
|
to_treat = [(formula, formula, forced_date_scope) for (formula, forced_date_scope) in formulas_dict.keys()] # Formed like [(expanded formula, original unexpanded formula)]
|
|
term_separator_regex = r'(?<!\de)[+-]|[ ()/*]'
|
|
term_replacement_regex = r"(^|(?<=[ ()+/*-]))%s((?=[ ()+/*-])|$)"
|
|
while to_treat:
|
|
formula, unexpanded_formula, forced_date_scope = to_treat.pop(0)
|
|
|
|
full_eval_dict = {**current_report_eval_dict, **other_reports_eval_dict.get(forced_date_scope, {})}
|
|
full_codes_map = {**current_report_codes_map, **other_reports_codes_map.get(forced_date_scope, {})}
|
|
|
|
# Evaluate the formula
|
|
terms_to_eval = [term for term in re.split(term_separator_regex, formula) if term and not _check_is_float(term)]
|
|
if terms_to_eval:
|
|
# The formula can't be evaluated as-is. Replace the terms by their value or formula,
|
|
# and enqueue the formula back; it'll be tried anew later in the loop.
|
|
for term in terms_to_eval:
|
|
try:
|
|
expanded_term = _resolve_subformula_on_dict(
|
|
full_eval_dict,
|
|
full_codes_map,
|
|
term,
|
|
)
|
|
except KeyError:
|
|
if term in aggregations_terms_to_evaluate:
|
|
# Then, the term is probably an aggregation with bounds that still needs to be computed. We need to keep on looping
|
|
continue
|
|
else:
|
|
raise UserError(_("Could not expand term %(term)s while evaluating formula %(unexpanded_formula)s", term=term, unexpanded_formula=unexpanded_formula))
|
|
|
|
formula = re.sub(term_replacement_regex % re.escape(term), f'({expanded_term})', formula)
|
|
|
|
to_treat.append((formula, unexpanded_formula, forced_date_scope))
|
|
|
|
else:
|
|
# The formula contains only digits and operators; it can be evaluated
|
|
if all(expr.subformula == "ignore_zero_division" for expr in formulas_dict[(unexpanded_formula, forced_date_scope)]):
|
|
try:
|
|
formula_result = expr_eval(formula)
|
|
except ZeroDivisionError:
|
|
# Arbitrary choice; for clarity of the report. A 0 division could typically happen when there is no result in the period.
|
|
formula_result = 0
|
|
else:
|
|
formula_result = expr_eval(formula)
|
|
|
|
for expression in formulas_dict[(unexpanded_formula, forced_date_scope)]:
|
|
# Apply subformula
|
|
if expression.subformula and expression.subformula.startswith('if_other_expr_'):
|
|
other_expr_criterium_match = re.match(
|
|
r"^(?P<criterium>\w+)\("
|
|
r"(?P<line_code>\w+)[.](?P<expr_label>\w+),[ ]*"
|
|
r"(?P<bound_params>.*)\)$",
|
|
expression.subformula
|
|
)
|
|
if not other_expr_criterium_match:
|
|
raise UserError(_("Wrong format for if_other_expr_above/if_other_expr_below formula: %s", expression.subformula))
|
|
|
|
criterium_code = other_expr_criterium_match['line_code']
|
|
criterium_label = other_expr_criterium_match['expr_label']
|
|
criterium_expression_id = full_codes_map.get(criterium_code, {}).get(criterium_label)
|
|
criterium_val = full_eval_dict.get(criterium_expression_id)
|
|
|
|
if not criterium_expression_id:
|
|
raise UserError(_("This subformula references an unknown expression: %s", expression.subformula))
|
|
|
|
if not isinstance(criterium_val, (float, int)):
|
|
# The criterium expression has not be evaluated yet. Postpone the evaluation of this formula, and skip this expression
|
|
# for now. We still try to evaluate other expressions using this formula if any; this means those expressions will
|
|
# be processed a second time later, giving the same result. This is a rare corner case, and not so costly anyway.
|
|
to_treat.append((formula, unexpanded_formula, forced_date_scope))
|
|
continue
|
|
|
|
bound_subformula = other_expr_criterium_match['criterium'].replace('other_expr_', '') # e.g. 'if_other_expr_above' => 'if_above'
|
|
bound_params = other_expr_criterium_match['bound_params']
|
|
bound_value = self._aggregation_apply_bounds(column_group_options, f"{bound_subformula}({bound_params})", criterium_val)
|
|
expression_result = formula_result * int(bool(bound_value))
|
|
|
|
else:
|
|
expression_result = self._aggregation_apply_bounds(column_group_options, expression.subformula, formula_result)
|
|
|
|
if column_group_options.get('integer_rounding_enabled'):
|
|
expression_result = float_round(expression_result, precision_digits=0, rounding_method=column_group_options['integer_rounding'])
|
|
|
|
# Store result
|
|
standardized_expression_scope = self._standardize_date_scope_for_date_range(expression.date_scope)
|
|
if (forced_date_scope == standardized_expression_scope or not forced_date_scope) and expression.report_line_id.report_id == self:
|
|
# This condition ensures we don't return necessary subcomputations in the final result
|
|
rslt[(unexpanded_formula, expression)] = {'result': expression_result}
|
|
|
|
# Handle recursive aggregations (explicit or through the sum_children shortcut).
|
|
# We need to make the result of our computation available to other aggregations, as they are still waiting in to_treat to be evaluated.
|
|
if expression.report_line_id.report_id == self:
|
|
current_report_eval_dict[expression.id] = expression_result
|
|
else:
|
|
other_reports_eval_dict.setdefault(forced_date_scope, {})[expression.id] = expression_result
|
|
|
|
return rslt
|
|
|
|
def _aggregation_apply_bounds(self, column_group_options, subformula, unbound_value):
|
|
""" Applies the bounds of the provided aggregation expression to an unbounded value that got computed for it and returns the result.
|
|
Bounds can be defined as subformulas of aggregation expressions, with the following possible values:
|
|
|
|
- if_above(CUR(bound_value)):
|
|
=> Result will be 0 if it's <= the provided bound value; else it'll be unbound_value
|
|
|
|
- if_below(CUR(bound_value)):
|
|
=> Result will be 0 if it's >= the provided bound value; else it'll be unbound_value
|
|
|
|
- if_between(CUR(bound_value1), CUR(bound_value2)):
|
|
=> Result will be unbound_value if it's strictly between the provided bounds. Else, it will
|
|
be brought back to the closest bound.
|
|
|
|
- round(decimal_places):
|
|
=> Result will be round(unbound_value, decimal_places)
|
|
|
|
(where CUR is a currency code, and bound_value* are float amounts in CUR currency)
|
|
"""
|
|
if not subformula:
|
|
return unbound_value
|
|
|
|
# So an expression can't have bounds and be cross_reports, for simplicity.
|
|
# To do that, just split the expression in two parts.
|
|
if subformula and subformula.startswith('round'):
|
|
precision_string = re.match(r"round\((?P<precision>\d+)\)", subformula)['precision']
|
|
return round(unbound_value, int(precision_string))
|
|
|
|
if subformula not in {'cross_report', 'ignore_zero_division'}:
|
|
company_currency = self.env.company.currency_id
|
|
date_to = column_group_options['date']['date_to']
|
|
|
|
match = re.match(
|
|
r"(?P<criterium>\w*)"
|
|
r"\((?P<currency_1>[A-Z]{3})\((?P<amount_1>[-]?\d+(\.\d+)?)\)"
|
|
r"(,(?P<currency_2>[A-Z]{3})\((?P<amount_2>[-]?\d+(\.\d+)?)\))?\)$",
|
|
subformula.replace(' ', '')
|
|
)
|
|
group_values = match.groupdict()
|
|
|
|
# Convert the provided bounds into company currency
|
|
currency_code_1 = group_values.get('currency_1')
|
|
currency_code_2 = group_values.get('currency_2')
|
|
currency_codes = [
|
|
currency_code
|
|
for currency_code in [currency_code_1, currency_code_2]
|
|
if currency_code and currency_code != company_currency.name
|
|
]
|
|
|
|
if currency_codes:
|
|
currencies = self.env['res.currency'].with_context(active_test=False).search([('name', 'in', currency_codes)])
|
|
else:
|
|
currencies = self.env['res.currency']
|
|
|
|
amount_1 = float(group_values['amount_1'] or 0)
|
|
amount_2 = float(group_values['amount_2'] or 0)
|
|
for currency in currencies:
|
|
if currency != company_currency:
|
|
if currency.name == currency_code_1:
|
|
amount_1 = currency._convert(amount_1, company_currency, self.env.company, date_to)
|
|
if amount_2 and currency.name == currency_code_2:
|
|
amount_2 = currency._convert(amount_2, company_currency, self.env.company, date_to)
|
|
|
|
# Evaluate result
|
|
criterium = group_values['criterium']
|
|
if criterium == 'if_below':
|
|
if company_currency.compare_amounts(unbound_value, amount_1) >= 0:
|
|
return 0
|
|
elif criterium == 'if_above':
|
|
if company_currency.compare_amounts(unbound_value, amount_1) <= 0:
|
|
return 0
|
|
elif criterium == 'if_between':
|
|
if company_currency.compare_amounts(unbound_value, amount_1) < 0 or company_currency.compare_amounts(unbound_value, amount_2) > 0:
|
|
return 0
|
|
else:
|
|
raise UserError(_("Unknown bound criterium: %s", criterium))
|
|
|
|
return unbound_value
|
|
|
|
def _compute_formula_batch(self, column_group_options, formula_engine, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
|
|
""" Evaluates a batch of formulas.
|
|
|
|
:param column_group_options: The options for the column group being evaluated, as obtained from _split_options_per_column_group.
|
|
|
|
:param formula_engine: A string identifying a report engine. Must be one of account.report.expression's engine field's technical labels.
|
|
|
|
:param date_scope: The date_scope under which to evaluate the fomulas. Must be one of account.report.expression's date_scope field's
|
|
technical labels.
|
|
|
|
:param formulas_dict: A dict in the dict(formula, expressions), where:
|
|
- formula: a formula to be evaluated with the engine referred to by parent dict key
|
|
- expressions: a recordset of all the expressions to evaluate using formula (possibly with distinct subformulas)
|
|
|
|
:param current_groupby: The groupby to evaluate, or None if there isn't any. In case of multi-level groupby, only contains the element
|
|
that needs to be computed (so, if unfolding a line doing 'partner_id,account_id,id'; current_groupby will only be
|
|
'partner_id'). Subsequent groupby will be in next_groupby.
|
|
|
|
:param next_groupby: Full groupby string of the groups that will have to be evaluated next for these expressions, or None if there isn't any.
|
|
For example, in the case depicted in the example of current_groupby, next_groupby will be 'account_id,id'.
|
|
|
|
:param offset: The SQL offset to use when computing the result of these expressions.
|
|
|
|
:param limit: The SQL limit to apply when computing these expressions' result.
|
|
|
|
:return: The result might have two different formats depending on the situation:
|
|
- if we're computing a groupby: {(formula, expressions): [(grouping_key, {'result': value, 'has_sublines': boolean}), ...], ...}
|
|
- if we're not: {(formula, expressions): {'result': value, 'has_sublines': boolean}, ...}
|
|
'result' key is the default; different engines might use one or multiple other keys instead, depending of the subformulas they allow
|
|
(e.g. 'sum', 'sum_if_pos', ...)
|
|
"""
|
|
engine_function_name = f'_compute_formula_batch_with_engine_{formula_engine}'
|
|
return getattr(self, engine_function_name)(
|
|
column_group_options, date_scope, formulas_dict, current_groupby, next_groupby,
|
|
offset=offset, limit=limit, warnings=warnings,
|
|
)
|
|
|
|
def _compute_formula_batch_with_engine_tax_tags(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
|
|
""" Report engine.
|
|
|
|
The formulas made for this report simply consist of a tag label. When an expression using this engine is created, it also creates two
|
|
account.account.tag objects, namely -tag and +tag, where tag is the chosen formula. The balance of the expressions using this engine is
|
|
computed by gathering all the move lines using their tags, and applying the sign of their tag to their balance, together with a -1 factor
|
|
if the tax_tag_invert field of the move line is True.
|
|
|
|
This engine does not support any subformula.
|
|
"""
|
|
self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
|
|
all_expressions = self.env['account.report.expression']
|
|
for expressions in formulas_dict.values():
|
|
all_expressions |= expressions
|
|
tags = all_expressions._get_matching_tags()
|
|
|
|
groupby_sql = SQL.identifier('account_move_line', current_groupby) if current_groupby else None
|
|
query = self._get_report_query(options, date_scope)
|
|
tail_query = self._get_engine_query_tail(offset, limit)
|
|
lang = get_lang(self.env, self.env.user.lang).code
|
|
acc_tag_name = self.with_context(lang='en_US').env['account.account.tag']._field_to_sql('acc_tag', 'name')
|
|
sql = SQL(
|
|
"""
|
|
SELECT
|
|
SUBSTRING(%(acc_tag_name)s, 2, LENGTH(%(acc_tag_name)s) - 1) AS formula,
|
|
SUM(%(balance_select)s
|
|
* CASE WHEN acc_tag.tax_negate THEN -1 ELSE 1 END
|
|
* CASE WHEN account_move_line.tax_tag_invert THEN -1 ELSE 1 END
|
|
) AS balance,
|
|
COUNT(account_move_line.id) AS aml_count
|
|
%(select_groupby_sql)s
|
|
|
|
FROM %(table_references)s
|
|
|
|
JOIN account_account_tag_account_move_line_rel aml_tag
|
|
ON aml_tag.account_move_line_id = account_move_line.id
|
|
JOIN account_account_tag acc_tag
|
|
ON aml_tag.account_account_tag_id = acc_tag.id
|
|
AND acc_tag.id IN %(tag_ids)s
|
|
%(currency_table_join)s
|
|
|
|
WHERE %(search_condition)s
|
|
|
|
GROUP BY %(groupby_clause)s
|
|
|
|
ORDER BY %(groupby_clause)s
|
|
|
|
%(tail_query)s
|
|
""",
|
|
acc_tag_name=acc_tag_name,
|
|
select_groupby_sql=SQL(', %s AS grouping_key', groupby_sql) if groupby_sql else SQL(),
|
|
table_references=query.from_clause,
|
|
tag_ids=tuple(tags.ids),
|
|
balance_select=self._currency_table_apply_rate(SQL("account_move_line.balance")),
|
|
currency_table_join=self._currency_table_aml_join(options),
|
|
search_condition=query.where_clause,
|
|
groupby_clause=SQL(
|
|
"SUBSTRING(%(acc_tag_name)s, 2, LENGTH(%(acc_tag_name)s) - 1)%(groupby_sql)s",
|
|
acc_tag_name=acc_tag_name,
|
|
groupby_sql=SQL(', %s', groupby_sql) if groupby_sql else SQL(),
|
|
),
|
|
tail_query=tail_query,
|
|
)
|
|
|
|
self.env.cr.execute(sql)
|
|
|
|
rslt = {formula_expr: [] if current_groupby else {'result': 0, 'has_sublines': False} for formula_expr in formulas_dict.items()}
|
|
for query_res in self.env.cr.dictfetchall():
|
|
|
|
formula = query_res['formula']
|
|
rslt_dict = {'result': query_res['balance'], 'has_sublines': query_res['aml_count'] > 0}
|
|
if current_groupby:
|
|
rslt[(formula, formulas_dict[formula])].append((query_res['grouping_key'], rslt_dict))
|
|
else:
|
|
rslt[(formula, formulas_dict[formula])] = rslt_dict
|
|
|
|
return rslt
|
|
|
|
def _compute_formula_batch_with_engine_domain(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
|
|
""" Report engine.
|
|
|
|
Formulas made for this engine consist of a domain on account.move.line. Only those move lines will be used to compute the result.
|
|
|
|
This engine supports a few subformulas, each returning a slighlty different result:
|
|
- sum: the result will be sum of the matched move lines' balances
|
|
|
|
- sum_if_pos: the result will be the same as sum only if it's positive; else, it will be 0
|
|
|
|
- sum_if_neg: the result will be the same as sum only if it's negative; else, it will be 0
|
|
|
|
- count_rows: the result will be the number of sublines this expression has. If the parent report line has no groupby,
|
|
then it will be the number of matching amls. If there is a groupby, it will be the number of distinct grouping
|
|
keys at the first level of this groupby (so, if groupby is 'partner_id, account_id', the number of partners).
|
|
"""
|
|
def _format_result_depending_on_groupby(formula_rslt):
|
|
if not current_groupby:
|
|
if formula_rslt:
|
|
# There should be only one element in the list; we only return its totals (a dict) ; so that a list is only returned in case
|
|
# of a groupby being unfolded.
|
|
return formula_rslt[0][1]
|
|
else:
|
|
# No result at all
|
|
return {
|
|
'sum': 0,
|
|
'sum_if_pos': 0,
|
|
'sum_if_neg': 0,
|
|
'count_rows': 0,
|
|
'has_sublines': False,
|
|
}
|
|
return formula_rslt
|
|
|
|
self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
|
|
|
|
groupby_sql = SQL.identifier('account_move_line', current_groupby) if current_groupby else None
|
|
|
|
rslt = {}
|
|
|
|
for formula, expressions in formulas_dict.items():
|
|
try:
|
|
line_domain = literal_eval(formula)
|
|
except (ValueError, SyntaxError):
|
|
raise UserError(_(
|
|
'Invalid domain formula in expression "%(expression)s" of line "%(line)s": %(formula)s',
|
|
expression=expressions.label,
|
|
line=expressions.report_line_id.name,
|
|
formula=formula,
|
|
))
|
|
query = self._get_report_query(options, date_scope, domain=line_domain)
|
|
|
|
tail_query = self._get_engine_query_tail(offset, limit)
|
|
query = SQL(
|
|
"""
|
|
SELECT
|
|
COALESCE(SUM(%(balance_select)s), 0.0) AS sum,
|
|
COUNT(DISTINCT account_move_line.%(select_count_field)s) AS count_rows
|
|
%(select_groupby_sql)s
|
|
FROM %(table_references)s
|
|
%(currency_table_join)s
|
|
WHERE %(search_condition)s
|
|
%(group_by_groupby_sql)s
|
|
%(order_by_sql)s
|
|
%(tail_query)s
|
|
""",
|
|
select_count_field=SQL.identifier(next_groupby.split(',')[0] if next_groupby else 'id'),
|
|
select_groupby_sql=SQL(', %s AS grouping_key', groupby_sql) if groupby_sql else SQL(),
|
|
table_references=query.from_clause,
|
|
balance_select=self._currency_table_apply_rate(SQL("account_move_line.balance")),
|
|
currency_table_join=self._currency_table_aml_join(options),
|
|
search_condition=query.where_clause,
|
|
group_by_groupby_sql=SQL('GROUP BY %s', groupby_sql) if groupby_sql else SQL(),
|
|
order_by_sql=SQL(' ORDER BY %s', groupby_sql) if groupby_sql else SQL(),
|
|
tail_query=tail_query,
|
|
)
|
|
|
|
# Fetch the results.
|
|
formula_rslt = []
|
|
self.env.cr.execute(query)
|
|
all_query_res = self.env.cr.dictfetchall()
|
|
|
|
total_sum = 0
|
|
for query_res in all_query_res:
|
|
res_sum = query_res['sum']
|
|
total_sum += res_sum
|
|
totals = {
|
|
'sum': res_sum,
|
|
'sum_if_pos': 0,
|
|
'sum_if_neg': 0,
|
|
'count_rows': query_res['count_rows'],
|
|
'has_sublines': query_res['count_rows'] > 0,
|
|
}
|
|
formula_rslt.append((query_res.get('grouping_key', None), totals))
|
|
|
|
# Handle sum_if_pos, -sum_if_pos, sum_if_neg and -sum_if_neg
|
|
expressions_by_sign_policy = defaultdict(lambda: self.env['account.report.expression'])
|
|
for expression in expressions:
|
|
subformula_without_sign = expression.subformula.replace('-', '').strip()
|
|
if subformula_without_sign in ('sum_if_pos', 'sum_if_neg'):
|
|
expressions_by_sign_policy[subformula_without_sign] += expression
|
|
else:
|
|
expressions_by_sign_policy['no_sign_check'] += expression
|
|
|
|
# Then we have to check the total of the line and only give results if its sign matches the desired policy.
|
|
# This is important for groupby managements, for which we can't just check the sign query_res by query_res
|
|
if expressions_by_sign_policy['sum_if_pos'] or expressions_by_sign_policy['sum_if_neg']:
|
|
sign_policy_with_value = 'sum_if_pos' if self.env.company.currency_id.compare_amounts(total_sum, 0.0) >= 0 else 'sum_if_neg'
|
|
# >= instead of > is intended; usability decision: 0 is considered positive
|
|
|
|
formula_rslt_with_sign = [(grouping_key, {**totals, sign_policy_with_value: totals['sum']}) for grouping_key, totals in formula_rslt]
|
|
|
|
for sign_policy in ('sum_if_pos', 'sum_if_neg'):
|
|
policy_expressions = expressions_by_sign_policy[sign_policy]
|
|
|
|
if policy_expressions:
|
|
if sign_policy == sign_policy_with_value:
|
|
rslt[(formula, policy_expressions)] = _format_result_depending_on_groupby(formula_rslt_with_sign)
|
|
else:
|
|
rslt[(formula, policy_expressions)] = _format_result_depending_on_groupby([])
|
|
|
|
if expressions_by_sign_policy['no_sign_check']:
|
|
rslt[(formula, expressions_by_sign_policy['no_sign_check'])] = _format_result_depending_on_groupby(formula_rslt)
|
|
|
|
return rslt
|
|
|
|
def _compute_formula_batch_with_engine_account_codes(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
|
|
r""" Report engine.
|
|
|
|
Formulas made for this engine target account prefixes. Each of the prefix used in the formula will be evaluated as the sum of the move
|
|
lines made on the accounts matching it. Those prefixes can be used together with arithmetic operations to perform them on the obtained
|
|
results.
|
|
Example: '123 - 456' will substract the balance of all account starting with 456 from the one of all accounts starting with 123.
|
|
|
|
It is also possible to exclude some subprefixes, with \ operator.
|
|
Example: '123\(1234)' will match prefixes all accounts starting with '123', except the ones starting with '1234'
|
|
|
|
To only match the balance of an account is it's positive (debit) or negative (credit), the letter D or C can be put just next to the prefix:
|
|
Example '123D': will give the total balance of accounts starting with '123' if it's positive, else it will be evaluated as 0.
|
|
|
|
Multiple subprefixes can be excluded if needed.
|
|
Example: '123\(1234,1236)
|
|
|
|
All these syntaxes can be mixed together.
|
|
Example: '123D\(1235) + 56 - 416C'
|
|
|
|
Note: if C or D character needs to be part of the prefix, it is possible to differentiate them of debit and credit match characters
|
|
by using an empty prefix exclusion.
|
|
Example 1: '123D\' will take the total balance of accounts starting with '123D'
|
|
Example 2: '123D\C' will return the balance of accounts starting with '123D' if it's negative, 0 otherwise.
|
|
"""
|
|
self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
|
|
|
|
# Gather the account code prefixes to compute the total from
|
|
prefix_details_by_formula = {} # in the form {formula: [(1, prefix1), (-1, prefix2)]}
|
|
prefixes_to_compute = set()
|
|
for formula in formulas_dict:
|
|
prefix_details_by_formula[formula] = []
|
|
for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(formula.replace(' ', '')):
|
|
if token:
|
|
token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token)
|
|
|
|
if not token_match:
|
|
raise UserError(_("Invalid token '%(token)s' in account_codes formula '%(formula)s'", token=token, formula=formula))
|
|
|
|
parsed_token = token_match.groupdict()
|
|
|
|
if not parsed_token:
|
|
raise UserError(_("Could not parse account_code formula from token '%s'", token))
|
|
|
|
multiplicator = -1 if parsed_token['sign'] == '-' else 1
|
|
excluded_prefixes_match = token_match['excluded_prefixes']
|
|
excluded_prefixes = excluded_prefixes_match.split(',') if excluded_prefixes_match else []
|
|
prefix = token_match['prefix']
|
|
|
|
# We group using both prefix and excluded_prefixes as keys, for the case where two expressions would
|
|
# include the same prefix, but exlcude different prefixes (example 104\(1041) and 104\(1042))
|
|
prefix_key = (prefix, *excluded_prefixes)
|
|
prefix_details_by_formula[formula].append((multiplicator, prefix_key, token_match['balance_character']))
|
|
prefixes_to_compute.add((prefix, tuple(excluded_prefixes)))
|
|
|
|
# Create the subquery for the WITH linking our prefixes with account.account entries
|
|
all_prefixes_queries: list[SQL] = []
|
|
prefilter = self.env['account.account']._check_company_domain(self.get_report_company_ids(options))
|
|
for prefix, excluded_prefixes in prefixes_to_compute:
|
|
account_domain = [
|
|
*prefilter,
|
|
]
|
|
|
|
tag_match = ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(prefix)
|
|
|
|
if tag_match:
|
|
if tag_match['ref']:
|
|
tag_id = self.env['ir.model.data']._xmlid_to_res_id(tag_match['ref'])
|
|
else:
|
|
tag_id = int(tag_match['id'])
|
|
|
|
account_domain.append(('tag_ids', 'in', [tag_id]))
|
|
else:
|
|
account_domain.append(('code', '=like', f'{prefix}%'))
|
|
|
|
excluded_prefixes_domains = []
|
|
|
|
for excluded_prefix in excluded_prefixes:
|
|
excluded_prefixes_domains.append([('code', '=like', f'{excluded_prefix}%')])
|
|
|
|
if excluded_prefixes_domains:
|
|
account_domain.append('!')
|
|
account_domain += osv.expression.OR(excluded_prefixes_domains)
|
|
|
|
prefix_query = self.env['account.account']._where_calc(account_domain)
|
|
all_prefixes_queries.append(prefix_query.select(
|
|
SQL("%s AS prefix", [prefix, *excluded_prefixes]),
|
|
SQL("account_account.id AS account_id"),
|
|
))
|
|
|
|
# Build a map to associate each account with the prefixes it matches
|
|
accounts_prefix_map = defaultdict(list)
|
|
for prefix, account_id in self.env.execute_query(SQL(' UNION ALL ').join(all_prefixes_queries)):
|
|
accounts_prefix_map[account_id].append(tuple(prefix))
|
|
|
|
# Run main query
|
|
query = self._get_report_query(options, date_scope)
|
|
|
|
current_groupby_aml_sql = SQL.identifier('account_move_line', current_groupby) if current_groupby else SQL()
|
|
tail_query = self._get_engine_query_tail(offset, limit)
|
|
if current_groupby_aml_sql and tail_query:
|
|
tail_query_additional_groupby_where_sql = SQL(
|
|
"""
|
|
AND %(current_groupby_aml_sql)s IN (
|
|
SELECT DISTINCT %(current_groupby_aml_sql)s
|
|
FROM account_move_line
|
|
WHERE %(search_condition)s
|
|
ORDER BY %(current_groupby_aml_sql)s
|
|
%(tail_query)s
|
|
)
|
|
""",
|
|
current_groupby_aml_sql=current_groupby_aml_sql,
|
|
search_condition=query.where_clause,
|
|
tail_query=tail_query,
|
|
)
|
|
else:
|
|
tail_query_additional_groupby_where_sql = SQL()
|
|
|
|
extra_groupby_sql = SQL(", %s", current_groupby_aml_sql) if current_groupby_aml_sql else SQL()
|
|
extra_select_sql = SQL(", %s AS grouping_key", current_groupby_aml_sql) if current_groupby_aml_sql else SQL()
|
|
|
|
query = SQL(
|
|
"""
|
|
SELECT
|
|
account_move_line.account_id AS account_id,
|
|
SUM(%(balance_select)s) AS sum,
|
|
COUNT(account_move_line.id) AS aml_count
|
|
%(extra_select_sql)s
|
|
FROM %(table_references)s
|
|
%(currency_table_join)s
|
|
WHERE %(search_condition)s
|
|
%(tail_query_additional_groupby_where_sql)s
|
|
GROUP BY account_move_line.account_id%(extra_groupby_sql)s
|
|
%(order_by_sql)s
|
|
%(tail_query)s
|
|
""",
|
|
extra_select_sql=extra_select_sql,
|
|
table_references=query.from_clause,
|
|
balance_select=self._currency_table_apply_rate(SQL("account_move_line.balance")),
|
|
currency_table_join=self._currency_table_aml_join(options),
|
|
search_condition=query.where_clause,
|
|
extra_groupby_sql=extra_groupby_sql,
|
|
tail_query_additional_groupby_where_sql=tail_query_additional_groupby_where_sql,
|
|
order_by_sql=SQL('ORDER BY %s', SQL.identifier('account_move_line', current_groupby)) if current_groupby else SQL(),
|
|
tail_query=tail_query if not tail_query_additional_groupby_where_sql else SQL(),
|
|
)
|
|
self.env.cr.execute(query)
|
|
|
|
# Parse result
|
|
rslt = {}
|
|
|
|
res_by_prefix_account_id = {}
|
|
for query_res in self.env.cr.dictfetchall():
|
|
# Done this way so that we can run similar code for groupby and non-groupby
|
|
grouping_key = query_res['grouping_key'] if current_groupby else None
|
|
account_id = query_res['account_id']
|
|
for prefix_key in accounts_prefix_map[account_id]:
|
|
res_by_prefix_account_id.setdefault(prefix_key, {})\
|
|
.setdefault(account_id, [])\
|
|
.append((grouping_key, {'result': query_res['sum'], 'has_sublines': query_res['aml_count'] > 0}))
|
|
|
|
for formula, prefix_details in prefix_details_by_formula.items():
|
|
rslt_key = (formula, formulas_dict[formula])
|
|
rslt_destination = rslt.setdefault(rslt_key, [] if current_groupby else {'result': 0, 'has_sublines': False})
|
|
rslt_groups_by_grouping_keys = {}
|
|
for multiplicator, prefix_key, balance_character in prefix_details:
|
|
res_by_account_id = res_by_prefix_account_id.get(prefix_key, {})
|
|
|
|
for account_results in res_by_account_id.values():
|
|
account_total_value = sum(group_val['result'] for (group_key, group_val) in account_results)
|
|
comparator = self.env.company.currency_id.compare_amounts(account_total_value, 0.0)
|
|
|
|
# Manage balance_character.
|
|
if not balance_character or (balance_character == 'D' and comparator >= 0) or (balance_character == 'C' and comparator < 0):
|
|
|
|
for group_key, group_val in account_results:
|
|
rslt_group = {
|
|
**group_val,
|
|
'result': multiplicator * group_val['result'],
|
|
}
|
|
if not current_groupby:
|
|
rslt_destination['result'] += rslt_group['result']
|
|
rslt_destination['has_sublines'] = rslt_destination['has_sublines'] or rslt_group['has_sublines']
|
|
elif group_key in rslt_groups_by_grouping_keys:
|
|
# Will happen if the same grouping key is used on move lines with different accounts.
|
|
# This comes from the GROUPBY in the SQL query, which uses both grouping key and account.
|
|
# When this happens, we want to aggregate the results of each grouping key, to avoid duplicates in the end result.
|
|
already_treated_rslt_group = rslt_groups_by_grouping_keys[group_key]
|
|
already_treated_rslt_group['has_sublines'] = already_treated_rslt_group['has_sublines'] or rslt_group['has_sublines']
|
|
already_treated_rslt_group['result'] += rslt_group['result']
|
|
else:
|
|
rslt_groups_by_grouping_keys[group_key] = rslt_group
|
|
rslt_destination.append((group_key, rslt_group))
|
|
|
|
return rslt
|
|
|
|
def _compute_formula_batch_with_engine_external(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
|
|
""" Report engine.
|
|
|
|
This engine computes its result from the account.report.external.value objects that are linked to the expression.
|
|
|
|
Two different formulas are possible:
|
|
- sum: if the result must be the sum of all the external values in the period.
|
|
- most_recent: it the result must be the value of the latest external value in the period, which can be a number or a text
|
|
|
|
No subformula is allowed for this engine.
|
|
"""
|
|
self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
|
|
|
|
if current_groupby or next_groupby or offset or limit:
|
|
raise UserError(_("'external' engine does not support groupby, limit nor offset."))
|
|
|
|
# Date clause
|
|
date_from, date_to = self._get_date_bounds_info(options, date_scope)
|
|
external_value_domain = [('date', '<=', date_to)]
|
|
if date_from:
|
|
external_value_domain.append(('date', '>=', date_from))
|
|
|
|
# Company clause
|
|
external_value_domain.append(('company_id', 'in', self.get_report_company_ids(options)))
|
|
|
|
# Fiscal Position clause
|
|
fpos_option = options['fiscal_position']
|
|
if fpos_option == 'domestic':
|
|
external_value_domain.append(('foreign_vat_fiscal_position_id', '=', False))
|
|
elif fpos_option != 'all':
|
|
# Then it's a fiscal position id
|
|
external_value_domain.append(('foreign_vat_fiscal_position_id', '=', int(fpos_option)))
|
|
|
|
# Do the computation
|
|
where_clause = self.env['account.report.external.value']._where_calc(external_value_domain).where_clause
|
|
|
|
# We have to execute two separate queries, one for text values and one for numeric values
|
|
num_queries = []
|
|
string_queries = []
|
|
monetary_queries = []
|
|
for formula, expressions in formulas_dict.items():
|
|
query_end = SQL()
|
|
if formula == 'most_recent':
|
|
query_end = SQL(
|
|
"""
|
|
GROUP BY date
|
|
ORDER BY date DESC
|
|
LIMIT 1
|
|
""",
|
|
)
|
|
string_query = """
|
|
SELECT %(expression_id)s, text_value
|
|
FROM account_report_external_value
|
|
WHERE %(where_clause)s AND target_report_expression_id = %(expression_id)s
|
|
"""
|
|
monetary_query = """
|
|
SELECT
|
|
%(expression_id)s,
|
|
COALESCE(SUM(COALESCE(%(balance_select)s, 0)), 0)
|
|
FROM account_report_external_value
|
|
%(currency_table_join)s
|
|
WHERE %(where_clause)s AND target_report_expression_id = %(expression_id)s
|
|
%(query_end)s
|
|
"""
|
|
num_query = """
|
|
SELECT %(expression_id)s, SUM(COALESCE(value, 0))
|
|
FROM account_report_external_value
|
|
WHERE %(where_clause)s AND target_report_expression_id = %(expression_id)s
|
|
%(query_end)s
|
|
"""
|
|
|
|
for expression in expressions:
|
|
if expression.figure_type == "string":
|
|
string_queries.append(SQL(
|
|
string_query,
|
|
expression_id=expression.id,
|
|
where_clause=where_clause,
|
|
))
|
|
elif expression.figure_type == "monetary":
|
|
monetary_queries.append(SQL(
|
|
monetary_query,
|
|
expression_id=expression.id,
|
|
balance_select=self._currency_table_apply_rate(SQL("CAST(value AS numeric)")),
|
|
currency_table_join=SQL(
|
|
"""
|
|
JOIN %(currency_table)s
|
|
ON account_currency_table.company_id = account_report_external_value.company_id
|
|
AND account_currency_table.rate_type = 'current'
|
|
""",
|
|
currency_table=self._get_currency_table(options),
|
|
),
|
|
where_clause=where_clause,
|
|
query_end=query_end,
|
|
))
|
|
else:
|
|
num_queries.append(SQL(
|
|
num_query,
|
|
expression_id=expression.id,
|
|
where_clause=where_clause,
|
|
query_end=query_end,
|
|
))
|
|
|
|
# Convert to dict to have expression ids as keys
|
|
query_results_dict = {}
|
|
for query_list in (num_queries, string_queries, monetary_queries):
|
|
if query_list:
|
|
query_results = self.env.execute_query(SQL(' UNION ALL ').join(SQL("(%s)", query) for query in query_list))
|
|
query_results_dict.update(dict(query_results))
|
|
|
|
# Build result dict
|
|
rslt = {}
|
|
for formula, expressions in formulas_dict.items():
|
|
for expression in expressions:
|
|
expression_value = query_results_dict.get(expression.id)
|
|
# If expression_value is None, we have no previous value for this expression (set default at 0.0)
|
|
expression_value = expression_value or ('' if expression.figure_type == 'string' else 0.0)
|
|
rslt[(formula, expression)] = {'result': expression_value, 'has_sublines': False}
|
|
|
|
return rslt
|
|
|
|
def _compute_formula_batch_with_engine_custom(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
|
|
self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
|
|
|
|
rslt = {}
|
|
for formula, expressions in formulas_dict.items():
|
|
custom_engine_function = self._get_custom_report_function(formula, 'custom_engine')
|
|
rslt[(formula, expressions)] = custom_engine_function(
|
|
expressions, options, date_scope, current_groupby, next_groupby, offset=offset, limit=limit, warnings=warnings)
|
|
return rslt
|
|
|
|
def _get_engine_query_tail(self, offset, limit) -> SQL:
|
|
""" Helper to generate the OFFSET, LIMIT and ORDER conditions of formula engines' queries.
|
|
"""
|
|
query_tail = SQL()
|
|
|
|
if offset:
|
|
query_tail = SQL("%s OFFSET %s", query_tail, offset)
|
|
|
|
if limit:
|
|
query_tail = SQL("%s LIMIT %s", query_tail, limit)
|
|
|
|
return query_tail
|
|
|
|
def _generate_carryover_external_values(self, options):
|
|
""" Generates the account.report.external.value objects corresponding to this report's carryover under the provided options.
|
|
|
|
In case of multicompany setup, we need to split the carryover per company, for ease of audit, and so that the carryover isn't broken when
|
|
a company leaves a tax unit.
|
|
|
|
We first generate the carryover for the wholy-aggregated report, so that we can see what final result we want.
|
|
Indeed due to force_between, if_above and if_below conditions, each carryover might be different from the sum of the individidual companies'
|
|
carryover values. To handle this case, we generate each company's carryover values separately, then do a carryover adjustment on the
|
|
main company (main for tax units, first one selected else) in order to bring their total to the result we computed for the whole unit.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if len(options['column_groups']) > 1:
|
|
# The options must be forged in order to generate carryover values. Entering this conditions means this hasn't been done in the right way.
|
|
raise UserError(_("Carryover can only be generated for a single column group."))
|
|
|
|
# Get the expressions to evaluate from the report
|
|
carryover_expressions = self.line_ids.expression_ids.filtered(lambda x: x.label.startswith('_carryover_'))
|
|
expressions_to_evaluate = carryover_expressions._expand_aggregations()
|
|
|
|
# Expression totals for all selected companies
|
|
expression_totals_per_col_group = self._compute_expression_totals_for_each_column_group(expressions_to_evaluate, options)
|
|
expression_totals = expression_totals_per_col_group[list(options['column_groups'].keys())[0]]
|
|
carryover_values = {expression: expression_totals[expression]['value'] for expression in carryover_expressions}
|
|
|
|
if len(options['companies']) == 1:
|
|
company = self.env['res.company'].browse(self.get_report_company_ids(options))
|
|
self._create_carryover_for_company(options, company, {expr: result for expr, result in carryover_values.items()})
|
|
else:
|
|
multi_company_carryover_values_sum = defaultdict(lambda: 0)
|
|
|
|
column_group_key = next(col_group_key for col_group_key in options['column_groups'])
|
|
for company_opt in options['companies']:
|
|
company = self.env['res.company'].browse(company_opt['id'])
|
|
company_options = {**options, 'companies': [{'id': company.id, 'name': company.name}]}
|
|
company_expressions_totals = self._compute_expression_totals_for_each_column_group(expressions_to_evaluate, company_options)
|
|
company_carryover_values = {expression: company_expressions_totals[column_group_key][expression]['value'] for expression in carryover_expressions}
|
|
self._create_carryover_for_company(options, company, company_carryover_values)
|
|
|
|
for carryover_expr, carryover_val in company_carryover_values.items():
|
|
multi_company_carryover_values_sum[carryover_expr] += carryover_val
|
|
|
|
# Adjust multicompany amounts on main company
|
|
main_company = self._get_sender_company_for_export(options)
|
|
for expr in carryover_expressions:
|
|
difference = carryover_values[expr] - multi_company_carryover_values_sum[expr]
|
|
self._create_carryover_for_company(options, main_company, {expr: difference}, label=_("Carryover adjustment for tax unit"))
|
|
|
|
@api.model
|
|
def _generate_default_external_values(self, date_from, date_to, is_tax_report=False):
|
|
""" Generates the account.report.external.value objects for the given dates.
|
|
If is_tax_report, the values are only created for tax reports, else for all other reports.
|
|
"""
|
|
options_dict = {}
|
|
default_expr_by_report = defaultdict(list)
|
|
tax_report = self.env.ref('account.generic_tax_report')
|
|
company = self.env.company
|
|
previous_options = {
|
|
'date': {
|
|
'date_from': date_from,
|
|
'date_to': date_to,
|
|
}
|
|
}
|
|
|
|
# Get all the default expressions from all reports
|
|
default_expressions = self.env['account.report.expression'].search([('label', '=like', '_default_%')])
|
|
# Options depend on the report, also we need to filter out tax report/other reports depending on is_tax_report
|
|
# Hence we need to group the default expressions by report
|
|
for expr in default_expressions:
|
|
report = expr.report_line_id.report_id
|
|
if is_tax_report == (tax_report in (report + report.root_report_id + report.section_main_report_ids.root_report_id)):
|
|
if report not in options_dict:
|
|
options = report.with_context(allowed_company_ids=[company.id]).get_options(previous_options)
|
|
options_dict[report] = options
|
|
|
|
if report._is_available_for(options_dict[report]):
|
|
default_expr_by_report[report].append(expr)
|
|
|
|
external_values_create_vals = []
|
|
for report, report_default_expressions in default_expr_by_report.items():
|
|
options = options_dict[report]
|
|
fpos_options = {options['fiscal_position']}
|
|
|
|
for available_fp in options['available_vat_fiscal_positions']:
|
|
fpos_options.add(available_fp['id'])
|
|
|
|
# remove 'all' from fiscal positions if we have several of them - all will then include the sum of other fps
|
|
# but if there aren't any other fps, we need to keep 'all'
|
|
if len(fpos_options) > 1 and 'all' in fpos_options:
|
|
fpos_options.remove('all')
|
|
|
|
# The default values should be created for every fiscal position available
|
|
for fiscal_pos in fpos_options:
|
|
fiscal_pos_id = int(fiscal_pos) if fiscal_pos not in {'domestic', 'all'} else None
|
|
fp_options = {**options, 'fiscal_position': fiscal_pos}
|
|
|
|
expressions_to_compute = {}
|
|
for default_expression in report_default_expressions:
|
|
# The default expression needs to have the same label as the target external expression, e.g. '_default_balance'
|
|
target_label = default_expression.label[len('_default_'):]
|
|
target_external_expression = default_expression.report_line_id.expression_ids.filtered(lambda x: x.label == target_label)
|
|
# If the value has been created before/modified manually, we shouldn't create anything
|
|
# and we won't recompute expression totals for them
|
|
external_value = self.env['account.report.external.value'].search([
|
|
('company_id', '=', company.id),
|
|
('date', '>=', date_from),
|
|
('date', '<=', date_to),
|
|
('foreign_vat_fiscal_position_id', '=', fiscal_pos_id),
|
|
('target_report_expression_id', '=', target_external_expression.id),
|
|
])
|
|
|
|
if not external_value:
|
|
expressions_to_compute[default_expression] = target_external_expression.id
|
|
|
|
# Evaluate the expressions for the report to fetch the value of the default expression
|
|
# These have to be computed for each fiscal position
|
|
expression_totals_per_col_group = report.with_company(company)\
|
|
._compute_expression_totals_for_each_column_group(expressions_to_compute, fp_options, include_default_vals=True)
|
|
expression_totals = expression_totals_per_col_group[list(fp_options['column_groups'].keys())[0]]
|
|
|
|
for expression, target_expression in expressions_to_compute.items():
|
|
external_values_create_vals.append({
|
|
'name': _("Manual value"),
|
|
'value': expression_totals[expression]['value'],
|
|
'date': date_to,
|
|
'target_report_expression_id': target_expression,
|
|
'foreign_vat_fiscal_position_id': fiscal_pos_id,
|
|
'company_id': company.id,
|
|
})
|
|
|
|
self.env['account.report.external.value'].create(external_values_create_vals)
|
|
|
|
@api.model
|
|
def _get_sender_company_for_export(self, options):
|
|
""" Return the sender company when generating an export file from this report.
|
|
:return: self.env.company if not using a tax unit, else the main company of that unit
|
|
"""
|
|
if options.get('tax_unit', 'company_only') != 'company_only':
|
|
tax_unit = self.env['account.tax.unit'].browse(options['tax_unit'])
|
|
return tax_unit.main_company_id
|
|
|
|
report_companies = self.env['res.company'].browse(self.get_report_company_ids(options))
|
|
options_main_company = report_companies[0]
|
|
|
|
if options.get('tax_unit') is not None and options_main_company._get_branches_with_same_vat() == report_companies:
|
|
# The line with the smallest number of parents in the VAT sub-hierarchy is assumed to be the root
|
|
return report_companies.sorted(lambda x: len(x.parent_ids))[0]
|
|
elif options_main_company._all_branches_selected():
|
|
return options_main_company.root_id
|
|
|
|
return options_main_company
|
|
|
|
def _create_carryover_for_company(self, options, company, carryover_per_expression, label=None):
|
|
date_from = options['date']['date_from']
|
|
date_to = options['date']['date_to']
|
|
fiscal_position_opt = options['fiscal_position']
|
|
|
|
if carryover_per_expression and fiscal_position_opt == 'all':
|
|
# Not supported, as it wouldn't make sense, and would make the code way more complicated (because of if_below/if_above/force_between,
|
|
# just in the same way as it is explained below for multi company)
|
|
raise UserError(_("Cannot generate carryover values for all fiscal positions at once!"))
|
|
|
|
external_values_create_vals = []
|
|
for expression, carryover_value in carryover_per_expression.items():
|
|
if not company.currency_id.is_zero(carryover_value):
|
|
target_expression = expression._get_carryover_target_expression(options)
|
|
external_values_create_vals.append({
|
|
'name': label or _("Carryover from %(date_from)s to %(date_to)s", date_from=format_date(self.env, date_from), date_to=format_date(self.env, date_to)),
|
|
'value': carryover_value,
|
|
'date': date_to,
|
|
'target_report_expression_id': target_expression.id,
|
|
'foreign_vat_fiscal_position_id': fiscal_position_opt if isinstance(fiscal_position_opt, int) else None,
|
|
'carryover_origin_expression_label': expression.label,
|
|
'carryover_origin_report_line_id': expression.report_line_id.id,
|
|
'company_id': company.id,
|
|
})
|
|
|
|
self.env['account.report.external.value'].create(external_values_create_vals)
|
|
|
|
def get_default_report_filename(self, options, extension):
|
|
"""The default to be used for the file when downloading pdf,xlsx,..."""
|
|
self.ensure_one()
|
|
|
|
sections_source_id = options['sections_source_id']
|
|
if sections_source_id != self.id:
|
|
sections_source = self.env['account.report'].browse(sections_source_id)
|
|
else:
|
|
sections_source = self
|
|
|
|
return f"{sections_source.name.lower().replace(' ', '_')}.{extension}"
|
|
|
|
def execute_action(self, options, params=None):
|
|
action_id = int(params.get('actionId'))
|
|
action = self.env['ir.actions.actions'].sudo().browse([action_id])
|
|
action_type = action.type
|
|
action = self.env[action.type].sudo().browse([action_id])
|
|
action_read = clean_action(action.read()[0], env=action.env)
|
|
|
|
if action_type == 'ir.actions.client':
|
|
# Check if we are opening another report. If so, generate options for it from the current options.
|
|
if action.tag == 'account_report':
|
|
target_report = self.env['account.report'].browse(ast.literal_eval(action_read['context'])['report_id'])
|
|
new_options = target_report.get_options(previous_options=options)
|
|
action_read.update({'params': {'options': new_options, 'ignore_session': True}})
|
|
|
|
if params.get('id'):
|
|
# Add the id of the calling object in the action's context
|
|
if isinstance(params['id'], int):
|
|
# id of the report line might directly be the id of the model we want.
|
|
model_id = params['id']
|
|
else:
|
|
# It can also be a generic account.report id, as defined by _get_generic_line_id
|
|
model_id = self._get_model_info_from_id(params['id'])[1]
|
|
|
|
context = action_read.get('context') and literal_eval(action_read['context']) or {}
|
|
context.setdefault('active_id', model_id)
|
|
action_read['context'] = context
|
|
|
|
return action_read
|
|
|
|
def action_audit_cell(self, options, params):
|
|
report_line = self.env['account.report.line'].browse(params['report_line_id'])
|
|
expression_label = params['expression_label']
|
|
expression = report_line.expression_ids.filtered(lambda x: x.label == expression_label)
|
|
column_group_options = self._get_column_group_options(options, params['column_group_key'])
|
|
|
|
# Audit of external values
|
|
if expression.engine == 'external':
|
|
date_from, date_to = self._get_date_bounds_info(column_group_options, expression.date_scope)
|
|
external_values_domain = [('target_report_expression_id', '=', expression.id), ('date', '<=', date_to)]
|
|
if date_from:
|
|
external_values_domain.append(('date', '>=', date_from))
|
|
|
|
if expression.formula == 'most_recent':
|
|
query = self.env['account.report.external.value']._where_calc(external_values_domain)
|
|
rows = self.env.execute_query(SQL("""
|
|
SELECT ARRAY_AGG(id)
|
|
FROM %s
|
|
WHERE %s
|
|
GROUP BY date
|
|
ORDER BY date DESC
|
|
LIMIT 1
|
|
""", query.from_clause, query.where_clause or SQL("TRUE")))
|
|
if rows:
|
|
external_values_domain = [('id', 'in', rows[0][0])]
|
|
|
|
return {
|
|
'name': _("Manual values"),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.report.external.value',
|
|
'view_mode': 'list',
|
|
'views': [(False, 'list')],
|
|
'domain': external_values_domain,
|
|
}
|
|
|
|
# Audit of move lines
|
|
# If we're auditing a groupby line, we need to make sure to restrict the result of what we audit to the right group values
|
|
column = next((col for col in report_line.report_id.column_ids if col.expression_label == expression_label), self.env['account.report.column'])
|
|
if column.custom_audit_action_id:
|
|
action_dict = column.custom_audit_action_id._get_action_dict()
|
|
else:
|
|
action_dict = {
|
|
'name': _("Journal Items"),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.move.line',
|
|
'view_mode': 'list',
|
|
'views': [(False, 'list')],
|
|
}
|
|
|
|
action = clean_action(action_dict, env=self.env)
|
|
action['domain'] = self._get_audit_line_domain(column_group_options, expression, params)
|
|
return action
|
|
|
|
def action_view_all_variants(self, options, params):
|
|
return {
|
|
'name': _('All Report Variants'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.report',
|
|
'view_mode': 'list',
|
|
'views': [(False, 'list'), (False, 'form')],
|
|
'context': {
|
|
'active_test': False,
|
|
},
|
|
'domain': [('id', 'in', self._get_variants(options['variants_source_id']).filtered(
|
|
lambda x: x._is_available_for(options)
|
|
).mapped('id'))],
|
|
}
|
|
|
|
def _get_audit_line_domain(self, column_group_options, expression, params):
|
|
groupby_domain = self._get_audit_line_groupby_domain(params['calling_line_dict_id'])
|
|
# Aggregate all domains per date scope, then create the final domain.
|
|
audit_or_domains_per_date_scope = {}
|
|
for expression_to_audit in expression._expand_aggregations():
|
|
expression_domain = self._get_expression_audit_aml_domain(expression_to_audit, column_group_options)
|
|
|
|
if expression_domain is None:
|
|
continue
|
|
|
|
date_scope = expression.date_scope if expression.subformula == 'cross_report' else expression_to_audit.date_scope
|
|
audit_or_domains = audit_or_domains_per_date_scope.setdefault(date_scope, [])
|
|
audit_or_domains.append(osv.expression.AND([
|
|
expression_domain,
|
|
groupby_domain,
|
|
]))
|
|
|
|
if audit_or_domains_per_date_scope:
|
|
domain = osv.expression.OR([
|
|
osv.expression.AND([
|
|
osv.expression.OR(audit_or_domains),
|
|
self._get_options_domain(column_group_options, date_scope),
|
|
groupby_domain,
|
|
])
|
|
for date_scope, audit_or_domains in audit_or_domains_per_date_scope.items()
|
|
])
|
|
else:
|
|
# Happens when no expression was provided (empty recordset), or if none of the expressions had a standard engine
|
|
domain = osv.expression.AND([
|
|
self._get_options_domain(column_group_options, 'strict_range'),
|
|
groupby_domain,
|
|
])
|
|
|
|
# Analytic Filter
|
|
if column_group_options.get("analytic_accounts"):
|
|
domain = osv.expression.AND([
|
|
domain,
|
|
[("analytic_distribution", "in", column_group_options["analytic_accounts"])],
|
|
])
|
|
|
|
return domain
|
|
|
|
def _get_audit_line_groupby_domain(self, calling_line_dict_id):
|
|
parsed_line_dict_id = self._parse_line_id(calling_line_dict_id)
|
|
groupby_domain = []
|
|
for markup, dummy, grouping_key in parsed_line_dict_id:
|
|
if isinstance(markup, dict) and 'groupby' in markup:
|
|
groupby_field_name = markup['groupby']
|
|
custom_handler_model = self._get_custom_handler_model()
|
|
if custom_handler_model and (custom_groupby_data := self.env[custom_handler_model]._get_custom_groupby_map().get(groupby_field_name)):
|
|
groupby_domain += custom_groupby_data['domain_builder'](grouping_key)
|
|
else:
|
|
groupby_domain.append((groupby_field_name, '=', grouping_key))
|
|
|
|
return groupby_domain
|
|
|
|
def _get_expression_audit_aml_domain(self, expression_to_audit, options):
|
|
""" Returns the domain used to audit a single provided expression.
|
|
|
|
'account_codes' engine's D and C formulas can't be handled by a domain: we make the choice to display
|
|
everything for them (so, audit shows all the lines that are considered by the formula). To avoid confusion from the user
|
|
when auditing such lines, a default group by account can be used in the list view.
|
|
"""
|
|
if expression_to_audit.engine == 'account_codes':
|
|
formula = expression_to_audit.formula.replace(' ', '')
|
|
|
|
account_codes_domains = []
|
|
for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(formula.replace(' ', '')):
|
|
if token:
|
|
match_dict = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token).groupdict()
|
|
tag_match = ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(match_dict['prefix'])
|
|
account_codes_domain = []
|
|
|
|
if tag_match:
|
|
if tag_match['ref']:
|
|
tag_id = self.env['ir.model.data']._xmlid_to_res_id(tag_match['ref'])
|
|
else:
|
|
tag_id = int(tag_match['id'])
|
|
|
|
account_codes_domain.append(('account_id.tag_ids', 'in', [tag_id]))
|
|
else:
|
|
account_codes_domain.append(('account_id.code', '=like', f"{match_dict['prefix']}%"))
|
|
|
|
excluded_prefix_str = match_dict['excluded_prefixes']
|
|
if excluded_prefix_str:
|
|
for excluded_prefix in excluded_prefix_str.split(','):
|
|
# "'not like', prefix%" doesn't work
|
|
account_codes_domain += ['!', ('account_id.code', '=like', f"{excluded_prefix}%")]
|
|
|
|
account_codes_domains.append(account_codes_domain)
|
|
|
|
return osv.expression.OR(account_codes_domains)
|
|
|
|
if expression_to_audit.engine == 'tax_tags':
|
|
tags = self.env['account.account.tag']._get_tax_tags(expression_to_audit.formula, expression_to_audit.report_line_id.report_id.country_id.id)
|
|
return [('tax_tag_ids', 'in', tags.ids)]
|
|
|
|
if expression_to_audit.engine == 'domain':
|
|
return ast.literal_eval(expression_to_audit.formula)
|
|
|
|
return None
|
|
|
|
def open_journal_items(self, options, params):
|
|
''' Open the journal items view with the proper filters and groups '''
|
|
record_model, record_id = self._get_model_info_from_id(params.get('line_id'))
|
|
view_id = self.env.ref(params['view_ref']).id if params.get('view_ref') else None
|
|
|
|
ctx = {
|
|
'search_default_group_by_account': 1,
|
|
'search_default_posted': 0 if options.get('all_entries') else 1,
|
|
'date_from': options.get('date').get('date_from'),
|
|
'date_to': options.get('date').get('date_to'),
|
|
'search_default_journal_id': params.get('journal_id', False),
|
|
'expand': 1,
|
|
}
|
|
|
|
if options['date'].get('date_from'):
|
|
ctx['search_default_date_between'] = 1
|
|
else:
|
|
ctx['search_default_date_before'] = 1
|
|
|
|
if options.get('selected_journal_groups'):
|
|
ctx.update({
|
|
'search_default_journal_group_id': [options['selected_journal_groups']['id']],
|
|
})
|
|
|
|
journal_type = params.get('journal_type')
|
|
if journal_type or options.get('selected_journal_groups') and options['selected_journal_groups']['journal_types']:
|
|
type_to_view_param = {
|
|
'bank': {
|
|
'filter': 'search_default_bank',
|
|
'view_id': self.env.ref('account.view_move_line_tree_grouped_bank_cash').id
|
|
},
|
|
'cash': {
|
|
'filter': 'search_default_cash',
|
|
'view_id': self.env.ref('account.view_move_line_tree_grouped_bank_cash').id
|
|
},
|
|
'general': {
|
|
'filter': 'search_default_misc_filter',
|
|
'view_id': self.env.ref('account.view_move_line_tree_grouped_misc').id
|
|
},
|
|
'sale': {
|
|
'filter': 'search_default_sales',
|
|
'view_id': self.env.ref('account.view_move_line_tree_grouped_sales_purchases').id
|
|
},
|
|
'purchase': {
|
|
'filter': 'search_default_purchases',
|
|
'view_id': self.env.ref('account.view_move_line_tree_grouped_sales_purchases').id
|
|
},
|
|
'credit': {
|
|
'filter': 'search_default_credit',
|
|
'view_id': self.env.ref('account.view_move_line_tree').id
|
|
},
|
|
}
|
|
if options.get('selected_journal_groups'):
|
|
ctx_to_update = {}
|
|
for journal_type in options['selected_journal_groups']['journal_types']:
|
|
ctx_to_update[type_to_view_param[journal_type]['filter']] = 1
|
|
ctx.update(ctx_to_update)
|
|
else:
|
|
ctx.update({
|
|
type_to_view_param[journal_type]['filter']: 1,
|
|
})
|
|
view_id = type_to_view_param[journal_type]['view_id']
|
|
|
|
action_domain = [('display_type', 'not in', ('line_section', 'line_note'))]
|
|
|
|
if record_id is None:
|
|
# Default filters don't support the 'no set' value. For this case, we use a domain on the action instead
|
|
model_fields_map = {
|
|
'account.account': 'account_id',
|
|
'res.partner': 'partner_id',
|
|
'account.journal': 'journal_id',
|
|
}
|
|
model_field = model_fields_map.get(record_model)
|
|
if model_field:
|
|
action_domain += [(model_field, '=', False)]
|
|
else:
|
|
model_default_filters = {
|
|
'account.account': 'search_default_account_id',
|
|
'res.partner': 'search_default_partner_id',
|
|
'account.journal': 'search_default_journal_id',
|
|
'product.product': 'search_default_product_id',
|
|
'product.category': 'search_default_product_category_id',
|
|
}
|
|
model_filter = model_default_filters.get(record_model)
|
|
if model_filter:
|
|
ctx.update({
|
|
'active_id': record_id,
|
|
model_filter: [record_id],
|
|
})
|
|
|
|
if options:
|
|
for account_type in options.get('account_type', []):
|
|
ctx.update({
|
|
f"search_default_{account_type['id']}": account_type['selected'] and 1 or 0,
|
|
})
|
|
|
|
if options.get('journals') and 'search_default_journal_id' not in ctx:
|
|
selected_journals = [journal['id'] for journal in options['journals'] if journal.get('selected')]
|
|
if len(selected_journals) == 1:
|
|
ctx['search_default_journal_id'] = selected_journals
|
|
|
|
if options.get('analytic_accounts'):
|
|
analytic_ids = [int(r) for r in options['analytic_accounts']]
|
|
ctx.update({
|
|
'search_default_analytic_accounts': 1,
|
|
'analytic_ids': analytic_ids,
|
|
})
|
|
|
|
return {
|
|
'name': self._get_action_name(params, record_model, record_id),
|
|
'view_mode': 'list,pivot,graph,kanban',
|
|
'res_model': 'account.move.line',
|
|
'views': [(view_id, 'list')],
|
|
'type': 'ir.actions.act_window',
|
|
'domain': action_domain,
|
|
'context': ctx,
|
|
}
|
|
|
|
def open_unposted_moves(self, options, params=None):
|
|
''' Open the list of draft journal entries that might impact the reporting'''
|
|
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
|
|
action = clean_action(action, env=self.env)
|
|
action['domain'] = [('state', '=', 'draft'), ('date', '<=', options['date']['date_to'])]
|
|
#overwrite the context to avoid default filtering on 'misc' journals
|
|
action['context'] = {}
|
|
return action
|
|
|
|
def _get_generated_deferral_entries_domain(self, options):
|
|
"""Get the search domain for the generated deferral entries of the current period.
|
|
|
|
:param options: the report's `options` dict containing `date_from`, `date_to` and `deferred_report_type`
|
|
:return: a search domain that can be used to get the deferral entries
|
|
"""
|
|
if options.get('deferred_report_type') == 'expense':
|
|
account_types = ('expense', 'expense_depreciation', 'expense_direct_cost')
|
|
else:
|
|
account_types = ('income', 'income_other')
|
|
date_to = fields.Date.from_string(options['date']['date_to'])
|
|
date_to_next_reversal = fields.Date.to_string(date_to + datetime.timedelta(days=1))
|
|
return [
|
|
('company_id', '=', self.env.company.id),
|
|
# We exclude the reversal entries of the previous period that fall on the first day of this period
|
|
('date', '>', options['date']['date_from']),
|
|
# We include the reversal entries of the current period that fall on the first day of the next period
|
|
('date', '<=', date_to_next_reversal),
|
|
('deferred_original_move_ids', '!=', False),
|
|
('line_ids.account_id.account_type', 'in', account_types),
|
|
('state', '!=', 'cancel'),
|
|
]
|
|
|
|
def open_deferral_entries(self, options, params):
|
|
domain = self._get_generated_deferral_entries_domain(options)
|
|
deferral_line_ids = self.env['account.move'].search(domain).line_ids.ids
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Deferred Entries'),
|
|
'res_model': 'account.move.line',
|
|
'domain': [('id', 'in', deferral_line_ids)],
|
|
'views': [(False, 'list'), (False, 'form')],
|
|
'context': {
|
|
'search_default_group_by_move': True,
|
|
'expand': True,
|
|
}
|
|
}
|
|
|
|
def action_modify_manual_value(self, line_id, options, column_group_key, new_value_str, target_expression_id, rounding, json_friendly_column_group_totals):
|
|
""" Edit a manual value from the report, updating or creating the corresponding account.report.external.value object.
|
|
|
|
:param options: The option dict the report is evaluated with.
|
|
|
|
:param column_group_key: The string identifying the column group into which the change as manual value needs to be done.
|
|
|
|
:param new_value_str: The new value to be set, as a string.
|
|
|
|
:param rounding: The number of decimal digits to round with.
|
|
|
|
:param json_friendly_column_group_totals: The expression totals by column group already computed for this report, in the format returned
|
|
by _get_json_friendly_column_group_totals. These will be used to reevaluate the report, recomputing
|
|
only the expressions depending on the newly-modified manual value, and keeping all the results
|
|
from the previous computations for the other ones.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
target_column_group_options = self._get_column_group_options(options, column_group_key)
|
|
self._init_currency_table(target_column_group_options)
|
|
|
|
if target_column_group_options.get('compute_budget'):
|
|
expressions_to_recompute = self.env['account.report.expression'].browse(target_expression_id) \
|
|
+ self.line_ids.expression_ids.filtered(lambda x: x.engine == 'aggregation')
|
|
self._action_modify_manual_budget_value(line_id, target_column_group_options, new_value_str, target_expression_id, rounding)
|
|
else:
|
|
expressions_to_recompute = self.line_ids.expression_ids.filtered(lambda x: x.engine in ('external', 'aggregation'))
|
|
self._action_modify_manual_external_value(target_column_group_options, new_value_str, target_expression_id, rounding)
|
|
|
|
# We recompute values for each column group, not only the one we modified a value in; this is important in case some date_scope is used to
|
|
# retrieve the manual value from a previous period.
|
|
|
|
all_column_groups_expression_totals = self._convert_json_friendly_column_group_totals(
|
|
json_friendly_column_group_totals,
|
|
expressions_to_exclude=expressions_to_recompute,
|
|
)
|
|
|
|
recomputed_expression_totals = self._compute_expression_totals_for_each_column_group(
|
|
expressions_to_recompute, options, forced_all_column_groups_expression_totals=all_column_groups_expression_totals)
|
|
|
|
return {
|
|
'lines': self._get_lines(options, all_column_groups_expression_totals=recomputed_expression_totals),
|
|
'column_groups_totals': self._get_json_friendly_column_group_totals(recomputed_expression_totals),
|
|
}
|
|
|
|
def _convert_json_friendly_column_group_totals(self, json_friendly_column_group_totals, expressions_to_exclude=None, col_groups_to_exclude=None):
|
|
""" json_friendly_column_group_totals contains ids instead of expressions (because it comes from js) ; this function is used
|
|
to convert them back to records.
|
|
"""
|
|
all_column_groups_expression_totals = {}
|
|
for column_group_key, expression_totals in json_friendly_column_group_totals.items():
|
|
if col_groups_to_exclude and column_group_key in col_groups_to_exclude:
|
|
continue
|
|
|
|
all_column_groups_expression_totals[column_group_key] = {}
|
|
for expr_id, expr_totals in expression_totals.items():
|
|
expression = self.env['account.report.expression'].browse(int(expr_id)) # Should already be in cache, so acceptable
|
|
if not expressions_to_exclude or expression not in expressions_to_exclude:
|
|
all_column_groups_expression_totals[column_group_key][expression] = expr_totals
|
|
|
|
return all_column_groups_expression_totals
|
|
|
|
def _action_modify_manual_external_value(self, target_column_group_options, new_value_str, target_expression_id, rounding):
|
|
""" Edit a manual value from the report, updating or creating the corresponding account.report.external.value object.
|
|
|
|
:param target_column_group_options: The options dict of the column group where the modification happened.
|
|
|
|
:param column_group_key: The string identifying the column group into which the change as manual value needs to be done.
|
|
|
|
:param new_value_str: The new value to be set, as a string.
|
|
|
|
:param rounding: The number of decimal digits to round with.
|
|
|
|
"""
|
|
if len(target_column_group_options['companies']) > 1:
|
|
raise UserError(_("Editing a manual report line is not allowed when multiple companies are selected."))
|
|
|
|
if target_column_group_options['fiscal_position'] == 'all' and target_column_group_options['available_vat_fiscal_positions']:
|
|
raise UserError(_("Editing a manual report line is not allowed in multivat setup when displaying data from all fiscal positions."))
|
|
|
|
# Create the manual value
|
|
target_expression = self.env['account.report.expression'].browse(target_expression_id)
|
|
date_from, date_to = self._get_date_bounds_info(target_column_group_options, target_expression.date_scope)
|
|
fiscal_position_id = target_column_group_options['fiscal_position'] if isinstance(target_column_group_options['fiscal_position'], int) else False
|
|
|
|
external_values_domain = [
|
|
('target_report_expression_id', '=', target_expression.id),
|
|
('company_id', '=', self.env.company.id),
|
|
('foreign_vat_fiscal_position_id', '=', fiscal_position_id),
|
|
]
|
|
|
|
if target_expression.formula == 'most_recent':
|
|
value_to_adjust = 0
|
|
existing_value_to_modify = self.env['account.report.external.value'].search([
|
|
*external_values_domain,
|
|
('date', '=', date_to),
|
|
])
|
|
|
|
# There should be at most 1
|
|
if len(existing_value_to_modify) > 1:
|
|
raise UserError(_("Inconsistent data: more than one external value at the same date for a 'most_recent' external line."))
|
|
else:
|
|
existing_external_values = self.env['account.report.external.value'].search([
|
|
*external_values_domain,
|
|
('date', '>=', date_from),
|
|
('date', '<=', date_to),
|
|
], order='date ASC')
|
|
existing_value_to_modify = existing_external_values[-1] if existing_external_values and str(existing_external_values[-1].date) == date_to else None
|
|
value_to_adjust = sum(existing_external_values.filtered(lambda x: x != existing_value_to_modify).mapped('value'))
|
|
|
|
if not new_value_str and target_expression.figure_type != 'string':
|
|
new_value_str = '0'
|
|
|
|
try:
|
|
float(new_value_str)
|
|
is_number = True
|
|
except ValueError:
|
|
is_number = False
|
|
|
|
if target_expression.figure_type == 'string':
|
|
value_to_set = new_value_str
|
|
else:
|
|
if not is_number:
|
|
raise UserError(_("%s is not a numeric value", new_value_str))
|
|
if target_expression.figure_type == 'boolean':
|
|
rounding = 0
|
|
value_to_set = float_round(float(new_value_str) - value_to_adjust, precision_digits=rounding)
|
|
|
|
field_name = 'value' if target_expression.figure_type != 'string' else 'text_value'
|
|
|
|
if existing_value_to_modify:
|
|
existing_value_to_modify[field_name] = value_to_set
|
|
existing_value_to_modify.flush_recordset()
|
|
else:
|
|
self.env['account.report.external.value'].create({
|
|
'name': _("Manual value"),
|
|
field_name: value_to_set,
|
|
'date': date_to,
|
|
'target_report_expression_id': target_expression.id,
|
|
'company_id': self.env.company.id,
|
|
'foreign_vat_fiscal_position_id': fiscal_position_id,
|
|
})
|
|
|
|
def _action_modify_manual_budget_value(self, line_id, target_column_group_options, new_value_str, target_expression_id, rounding):
|
|
target_expression = self.env['account.report.expression'].browse(target_expression_id)
|
|
|
|
if not new_value_str and target_expression.figure_type != 'string':
|
|
new_value_str = '0'
|
|
|
|
try:
|
|
value_to_set = float_round(float(new_value_str), precision_digits=rounding)
|
|
except ValueError:
|
|
raise UserError(_("%s is not a numeric value", new_value_str))
|
|
|
|
model, account_id = self._get_model_info_from_id(line_id)
|
|
if model != 'account.account':
|
|
raise UserError(_("Budget items can only be edited from account lines."))
|
|
|
|
# Depending on the expression's formula, the balance of the account could be multiplied by -1
|
|
# within the report. We need to apply the same multiplier on the budget item we create.
|
|
if target_expression.engine == 'domain' and target_expression.subformula.startswith('-'):
|
|
value_to_set *= -1
|
|
elif target_expression.engine == 'account_codes':
|
|
account = self.env['account.account'].browse(account_id)
|
|
|
|
# Search for the sign to apply to this account
|
|
for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(target_expression.formula.replace(' ', '')):
|
|
if not token:
|
|
continue
|
|
|
|
token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token)
|
|
multiplicator = -1 if token_match['sign'] == '-' else 1
|
|
prefix = token_match['prefix']
|
|
|
|
tag_match = ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(prefix)
|
|
if tag_match:
|
|
if tag_match['ref']:
|
|
tag = self.env.ref(tag_match['ref'])
|
|
else:
|
|
tag = self.env['account.account.tag'].browse(tag_match['id'])
|
|
|
|
account_matches = tag in account.tag_ids
|
|
else:
|
|
account_matches = account.code.startswith(prefix)
|
|
|
|
if account_matches:
|
|
value_to_set *= multiplicator
|
|
break
|
|
|
|
self.env['account.report.budget'].browse(target_column_group_options['compute_budget'])._create_or_update_budget_items(
|
|
value_to_set,
|
|
account_id,
|
|
rounding,
|
|
target_column_group_options['date']['date_from'],
|
|
target_column_group_options['date']['date_to'],
|
|
)
|
|
|
|
def action_display_inactive_sections(self, options):
|
|
self.ensure_one()
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _("Enable Sections"),
|
|
'view_mode': 'list,form',
|
|
'res_model': 'account.report',
|
|
'domain': [('section_main_report_ids', 'in', options['sections_source_id']), ('active', '=', False)],
|
|
'views': [(False, 'list'), (False, 'form')],
|
|
'context': {
|
|
'list_view_ref': 'fusion_accounting.account_report_add_sections_tree',
|
|
'active_test': False,
|
|
},
|
|
}
|
|
|
|
@api.model
|
|
def sort_lines(self, lines, options, result_as_index=False):
|
|
''' Sort report lines based on the 'order_column' key inside the options.
|
|
The value of options['order_column'] is an integer, positive or negative, indicating on which column
|
|
to sort and also if it must be an ascending sort (positive value) or a descending sort (negative value).
|
|
Note that for this reason, its indexing is made starting at 1, not 0.
|
|
If this key is missing or falsy, lines is returned directly.
|
|
|
|
This method has some limitations:
|
|
- The selected_column must have 'sortable' in its classes.
|
|
- All lines are sorted except:
|
|
- lines having the 'total' class
|
|
- static lines (lines with model 'account.report.line')
|
|
- This only works when each line has an unique id.
|
|
- All lines inside the selected_column must have a 'no_format' value.
|
|
|
|
Example:
|
|
|
|
parent_line_1 balance=11
|
|
child_line_1 balance=1
|
|
child_line_2 balance=3
|
|
child_line_3 balance=2
|
|
child_line_4 balance=7
|
|
child_line_5 balance=4
|
|
child_line_6 (total line)
|
|
parent_line_2 balance=10
|
|
child_line_7 balance=5
|
|
child_line_8 balance=6
|
|
child_line_9 (total line)
|
|
|
|
|
|
The resulting lines will be:
|
|
|
|
parent_line_2 balance=10
|
|
child_line_7 balance=5
|
|
child_line_8 balance=6
|
|
child_line_9 (total line)
|
|
parent_line_1 balance=11
|
|
child_line_1 balance=1
|
|
child_line_3 balance=2
|
|
child_line_2 balance=3
|
|
child_line_5 balance=4
|
|
child_line_4 balance=7
|
|
child_line_6 (total line)
|
|
|
|
:param lines: The report lines.
|
|
:param options: The report options.
|
|
:return: Lines sorted by the selected column.
|
|
'''
|
|
def needs_to_be_at_bottom(line_elem):
|
|
return self._get_markup(line_elem.get('id')) in ('total', 'load_more')
|
|
|
|
def compare_values(a_line, b_line):
|
|
if column_index is False:
|
|
return 0
|
|
type_seq = {
|
|
type(None): 0,
|
|
bool: 1,
|
|
float: 2,
|
|
int: 2,
|
|
str: 3,
|
|
datetime.date: 4,
|
|
datetime.datetime: 5,
|
|
}
|
|
|
|
a_line_dict = lines[a_line] if result_as_index else a_line
|
|
b_line_dict = lines[b_line] if result_as_index else b_line
|
|
a_total = needs_to_be_at_bottom(a_line_dict)
|
|
b_total = needs_to_be_at_bottom(b_line_dict)
|
|
a_model = self._get_model_info_from_id(a_line_dict['id'])[0]
|
|
b_model = self._get_model_info_from_id(b_line_dict['id'])[0]
|
|
|
|
# static lines are not sorted
|
|
if a_model == b_model == 'account.report.line':
|
|
return 0
|
|
|
|
if a_total:
|
|
if b_total: # a_total & b_total
|
|
return 0
|
|
else: # a_total & !b_total
|
|
return -1 if descending else 1
|
|
if b_total: # => !a_total & b_total
|
|
return 1 if descending else -1
|
|
|
|
a_val = a_line_dict['columns'][column_index].get('no_format')
|
|
b_val = b_line_dict['columns'][column_index].get('no_format')
|
|
type_a, type_b = type_seq[type(a_val)], type_seq[type(b_val)]
|
|
|
|
if type_a == type_b:
|
|
return 0 if a_val == b_val else 1 if a_val > b_val else -1
|
|
else:
|
|
return type_a - type_b
|
|
|
|
def merge_tree(tree_elem, ls):
|
|
nonlocal descending # The direction of the sort is needed to compare total lines
|
|
ls.append(tree_elem)
|
|
|
|
elem = tree[lines[tree_elem]['id']] if result_as_index else tree[tree_elem['id']]
|
|
|
|
for tree_subelem in sorted(elem, key=comp_key, reverse=descending):
|
|
merge_tree(tree_subelem, ls)
|
|
|
|
descending = options['order_column']['direction'] == 'DESC' # To keep total lines at the end, used in compare_values & merge_tree scopes
|
|
|
|
column_index = False
|
|
for index, col in enumerate(options['columns']):
|
|
if options['order_column']['expression_label'] == col['expression_label']:
|
|
column_index = index # To know from which column to sort, used in merge_tree scope
|
|
break
|
|
|
|
comp_key = cmp_to_key(compare_values)
|
|
sorted_list = []
|
|
tree = defaultdict(list)
|
|
non_total_parents = set()
|
|
|
|
for index, line in enumerate(lines):
|
|
line_parent = line.get('parent_id') or None
|
|
|
|
if result_as_index:
|
|
tree[line_parent].append(index)
|
|
else:
|
|
tree[line_parent].append(line)
|
|
|
|
line_markup = self._get_markup(line['id'])
|
|
|
|
if line_markup != 'total':
|
|
non_total_parents.add(line_parent)
|
|
|
|
if None not in tree and len(non_total_parents) == 1:
|
|
# Happens when unfolding a groupby line, to sort its children.
|
|
sorting_root = next(iter(non_total_parents))
|
|
else:
|
|
sorting_root = None
|
|
|
|
for line in sorted(tree[sorting_root], key=comp_key, reverse=descending):
|
|
merge_tree(line, sorted_list)
|
|
|
|
return sorted_list
|
|
|
|
def _get_annotations_domain_date_from(self, options):
|
|
if options['date']['filter'] in {'today', 'custom'} and options['date']['mode'] == 'single':
|
|
options_company_ids = [company['id'] for company in options['companies']]
|
|
root_companies_ids = self.env['res.company'].browse(options_company_ids).root_id.ids
|
|
fiscal_year = self.env['account.fiscal.year'].search_fetch([
|
|
('company_id', 'in', root_companies_ids),
|
|
('date_from', '<=', options['date']['date_to']),
|
|
('date_to', '>=', options['date']['date_to']),
|
|
], limit=1, field_names=['date_from'])
|
|
if fiscal_year:
|
|
return datetime.datetime.combine(fiscal_year.date_from, datetime.time.min)
|
|
|
|
period_date_from, _ = date_utils.get_fiscal_year(
|
|
datetime.datetime.strptime(options['date']['date_to'], '%Y-%m-%d'),
|
|
day=self.env.company.fiscalyear_last_day,
|
|
month=int(self.env.company.fiscalyear_last_month)
|
|
)
|
|
return period_date_from
|
|
|
|
date_from = datetime.datetime.strptime(options['date']['date_from'], '%Y-%m-%d')
|
|
if options['date']['period_type'] == "fiscalyear":
|
|
period_date_from, _ = date_utils.get_fiscal_year(date_from)
|
|
elif options['date']['period_type'] in ["year", "quarter", "month", "week", "day", "hour"]:
|
|
period_date_from = date_utils.start_of(date_from, options['date']['period_type'])
|
|
else:
|
|
period_date_from = date_from
|
|
return period_date_from
|
|
|
|
def _adjust_date_for_joined_comparison(self, options, period_date_from):
|
|
comparison_filter = options.get('comparison', {}).get('filter')
|
|
if comparison_filter == 'previous_period':
|
|
comparison_date_from = datetime.datetime.strptime(options['comparison'].get('periods', [{}])[-1].get('date_from'), '%Y-%m-%d')
|
|
return min(period_date_from, comparison_date_from)
|
|
return period_date_from
|
|
|
|
def _adjust_domain_for_unjoined_comparison(self, options, dates_domain):
|
|
comparison_filter = options.get('comparison', {}).get('filter')
|
|
if comparison_filter and comparison_filter not in {'no_comparison', 'previous_period'}:
|
|
unlinked_comparison_periods_domains_list = [
|
|
['&', ('date', '>=', period['date_from']), ('date', '<=', period['date_to'])]
|
|
for period in options['comparison']['periods']
|
|
]
|
|
dates_domain = osv.expression.OR([dates_domain, *unlinked_comparison_periods_domains_list])
|
|
|
|
return dates_domain
|
|
|
|
def _build_annotations_domain(self, options):
|
|
domain = [('report_id', '=', options['report_id'])]
|
|
if options.get('date'):
|
|
period_date_from = self._get_annotations_domain_date_from(options)
|
|
period_date_from = self._adjust_date_for_joined_comparison(options, period_date_from)
|
|
dates_domain = osv.expression.AND([
|
|
[('date', '>=', period_date_from)],
|
|
[('date', '<=', options['date']['date_to'])],
|
|
])
|
|
dates_domain = self._adjust_domain_for_unjoined_comparison(options, dates_domain)
|
|
|
|
domain = osv.expression.AND([
|
|
domain,
|
|
osv.expression.OR([
|
|
[('date', '=', False)],
|
|
dates_domain,
|
|
]),
|
|
])
|
|
|
|
fiscal_position_option = options.get('fiscal_position')
|
|
if isinstance(fiscal_position_option, int):
|
|
domain = osv.expression.AND([domain, [('fiscal_position_id', '=', fiscal_position_option)]])
|
|
elif fiscal_position_option == 'domestic':
|
|
domain = osv.expression.AND([domain, [('fiscal_position_id', '=', False)]])
|
|
return domain
|
|
|
|
def get_annotations(self, options):
|
|
"""
|
|
This method handles which annotations have to be displayed on the report.
|
|
This decision is based on the different dates and mode of display of those dates in the report.
|
|
|
|
param options: dict of options used to generate the report
|
|
return: dict of lists containing for each annotated line_id of the report the list of annotations linked to it
|
|
"""
|
|
self.ensure_one()
|
|
annotations_by_line = defaultdict(list)
|
|
annotations = self.env['account.report.annotation'].search_read(self._build_annotations_domain(options))
|
|
for annotation in annotations:
|
|
line_id_without_tax_grouping = self.env['account.report.annotation']._remove_tax_grouping_from_line_id(annotation['line_id'])
|
|
annotation['create_date'] = annotation['create_date'].date()
|
|
annotations_by_line[line_id_without_tax_grouping].append(annotation)
|
|
return annotations_by_line
|
|
|
|
def get_report_information(self, options):
|
|
"""
|
|
return a dictionary of information that will be consumed by the AccountReport component.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
warnings = {}
|
|
self._init_currency_table(options)
|
|
all_column_groups_expression_totals = self._compute_expression_totals_for_each_column_group(self.line_ids.expression_ids, options, warnings=warnings)
|
|
|
|
# Convert all_column_groups_expression_totals to a json-friendly form (its keys are records)
|
|
json_friendly_column_group_totals = self._get_json_friendly_column_group_totals(all_column_groups_expression_totals)
|
|
|
|
if self.custom_handler_model_name:
|
|
custom_display_config = self.env[self.custom_handler_model_name]._get_custom_display_config()
|
|
elif self.root_report_id and self.root_report_id.custom_handler_model_name:
|
|
custom_display_config = self.env[self.root_report_id.custom_handler_model_name]._get_custom_display_config()
|
|
else:
|
|
custom_display_config = {}
|
|
|
|
return {
|
|
'caret_options': self._get_caret_options(),
|
|
'column_headers_render_data': self._get_column_headers_render_data(options),
|
|
'column_groups_totals': json_friendly_column_group_totals,
|
|
'context': self.env.context,
|
|
'custom_display': custom_display_config,
|
|
'filters': {
|
|
'show_all': self.filter_unfold_all,
|
|
'show_analytic': options.get('display_analytic', False),
|
|
'show_analytic_groupby': options.get('display_analytic_groupby', False),
|
|
'show_analytic_plan_groupby': options.get('display_analytic_plan_groupby', False),
|
|
'show_draft': self.filter_show_draft,
|
|
'show_hierarchy': options.get('display_hierarchy_filter', False),
|
|
'show_period_comparison': self.filter_period_comparison,
|
|
'show_totals': self.env.company.totals_below_sections and not options.get('ignore_totals_below_sections'),
|
|
'show_unreconciled': self.filter_unreconciled,
|
|
'show_hide_0_lines': self.filter_hide_0_lines,
|
|
},
|
|
'annotations': self.get_annotations(options),
|
|
'groups': {
|
|
'analytic_accounting': self.env.user.has_group('analytic.group_analytic_accounting'),
|
|
'account_readonly': self.env.user.has_group('account.group_account_readonly'),
|
|
'account_user': self.env.user.has_group('account.group_account_user'),
|
|
},
|
|
'lines': self._get_lines(options, all_column_groups_expression_totals=all_column_groups_expression_totals, warnings=warnings),
|
|
'warnings': warnings,
|
|
'report': {
|
|
'company_name': self.env.company.name,
|
|
'company_country_code': self.env.company.country_code,
|
|
'company_currency_symbol': self.env.company.currency_id.symbol,
|
|
'name': self.name,
|
|
'root_report_id': self.root_report_id,
|
|
}
|
|
}
|
|
|
|
@api.readonly
|
|
def get_report_information_readonly(self, options):
|
|
""" Readonly version of get_report_information, to be called from RPC when options['readonly_query'] is True,
|
|
to better spread the load on servers when possible.
|
|
"""
|
|
return self.get_report_information(options)
|
|
|
|
def _get_json_friendly_column_group_totals(self, all_column_groups_expression_totals):
|
|
# Convert all_column_groups_expression_totals to a json-friendly form (its keys are records)
|
|
json_friendly_column_group_totals = {}
|
|
for column_group_key, expressions_totals in all_column_groups_expression_totals.items():
|
|
json_friendly_column_group_totals[column_group_key] = {expression.id: totals for expression, totals in expressions_totals.items()}
|
|
return json_friendly_column_group_totals
|
|
|
|
def _is_available_for(self, options):
|
|
""" Called on report variants to know whether they are available for the provided options or not, computed for their root report,
|
|
computing their availability_condition field.
|
|
|
|
Note that only the options initialized by the init_options with a more prioritary sequence than _init_options_variants are guaranteed to
|
|
be in the provided options' dict (since this function is called by _init_options_variants, while resolving a call to get_options()).
|
|
"""
|
|
self.ensure_one()
|
|
|
|
companies = self.env['res.company'].browse(self.get_report_company_ids(options))
|
|
|
|
if self.availability_condition == 'country':
|
|
countries = companies.account_fiscal_country_id
|
|
if self.filter_fiscal_position:
|
|
foreign_vat_fpos = self.env['account.fiscal.position'].search([
|
|
('foreign_vat', '!=', False),
|
|
('company_id', 'in', companies.ids),
|
|
])
|
|
countries += foreign_vat_fpos.country_id
|
|
|
|
return not self.country_id or self.country_id in countries
|
|
|
|
elif self.availability_condition == 'coa':
|
|
# When restricting to 'coa', the report is only available is all the companies have the same CoA as the report
|
|
return self.chart_template in set(companies.mapped('chart_template'))
|
|
|
|
return True
|
|
|
|
def _get_column_headers_render_data(self, options):
|
|
column_headers_render_data = {}
|
|
|
|
# We only want to consider the columns that are visible in the current report and don't rely on self.column_ids
|
|
# since custom reports could alter them (e.g. for multi-currency purposes)
|
|
columns = [col for col in options['columns'] if col['column_group_key'] == next(k for k in options['column_groups'])]
|
|
|
|
# Compute the colspan of each header level, aka the number of single columns it contains at the base of the hierarchy
|
|
level_colspan_list = column_headers_render_data['level_colspan'] = []
|
|
for i in range(len(options['column_headers'])):
|
|
colspan = max(len(columns), 1)
|
|
for column_header in options['column_headers'][i + 1:]:
|
|
# Separate non-budget and budget headers
|
|
budget_count = sum(
|
|
any(key in header.get('forced_options', {}) for key in ('compute_budget', 'budget_percentage'))
|
|
for header in column_header
|
|
)
|
|
non_budget_count = len(column_header) - budget_count
|
|
|
|
# budget headers (amount and percentage) can only contain a single column each, regardless of the amount of columns in the report.
|
|
# This implies that we first need to multiply for the 'regular' columns and then add the budget columns.
|
|
colspan *= non_budget_count
|
|
colspan += budget_count
|
|
|
|
level_colspan_list.append(colspan)
|
|
|
|
# Compute the number of times each header level will have to be repeated, and its colspan to properly handle horizontal groups/comparisons
|
|
column_headers_render_data['level_repetitions'] = []
|
|
for i in range(len(options['column_headers'])):
|
|
colspan = 1
|
|
for column_header in options['column_headers'][:i]:
|
|
colspan *= len(column_header)
|
|
column_headers_render_data['level_repetitions'].append(colspan)
|
|
|
|
# Custom reports have the possibility to define custom subheaders that will be displayed between the generic header and the column names.
|
|
column_headers_render_data['custom_subheaders'] = options.get('custom_columns_subheaders', []) * len(options['column_groups'])
|
|
|
|
return column_headers_render_data
|
|
|
|
def _get_action_name(self, params, record_model=None, record_id=None):
|
|
if not (record_model or record_id):
|
|
record_model, record_id = self._get_model_info_from_id(params.get('line_id'))
|
|
return params.get('name') or self.env[record_model].browse(record_id).display_name or ''
|
|
|
|
def _format_lines_for_display(self, lines, options):
|
|
"""
|
|
This method should be overridden in a report in order to apply specific formatting when printing
|
|
the report lines.
|
|
|
|
Used for example by the carryover functionnality in the generic tax report.
|
|
:param lines: A list with the lines for this report.
|
|
:param options: The options for this report.
|
|
:return: The formatted list of lines
|
|
"""
|
|
return lines
|
|
|
|
def get_expanded_lines(self, options, line_dict_id, groupby, expand_function_name, progress, offset, horizontal_split_side):
|
|
self.env.flush_all()
|
|
self._init_currency_table(options)
|
|
|
|
lines = self._expand_unfoldable_line(expand_function_name, line_dict_id, groupby, options, progress, offset, horizontal_split_side)
|
|
lines = self._fully_unfold_lines_if_needed(lines, options)
|
|
|
|
if self.custom_handler_model_id:
|
|
lines = self.env[self.custom_handler_model_name]._custom_line_postprocessor(self, options, lines)
|
|
|
|
self._format_column_values(options, lines)
|
|
return lines
|
|
|
|
@api.readonly
|
|
def get_expanded_lines_readonly(self, options, line_dict_id, groupby, expand_function_name, progress, offset, horizontal_split_side):
|
|
""" Readonly version of get_expanded_lines_readonly, to be called from RPC when options['readonly_query'] is True,
|
|
to better spread the load on servers when possible.
|
|
"""
|
|
return self.get_expanded_lines(options, line_dict_id, groupby, expand_function_name, progress, offset, horizontal_split_side)
|
|
|
|
def _expand_unfoldable_line(self, expand_function_name, line_dict_id, groupby, options, progress, offset, horizontal_split_side, unfold_all_batch_data=None):
|
|
if not expand_function_name:
|
|
raise UserError(_("Trying to expand a line without an expansion function."))
|
|
|
|
if not progress:
|
|
progress = {column_group_key: 0 for column_group_key in options['column_groups']}
|
|
|
|
expand_function = self._get_custom_report_function(expand_function_name, 'expand_unfoldable_line')
|
|
expansion_result = expand_function(line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=unfold_all_batch_data)
|
|
|
|
rslt = expansion_result['lines']
|
|
|
|
if horizontal_split_side:
|
|
for line in rslt:
|
|
line['horizontal_split_side'] = horizontal_split_side
|
|
|
|
# Apply integer rounding to the result if needed.
|
|
# The groupby expansion function is the only one guaranteed to call the expressions computation,
|
|
# so the values computed for it will already have been rounded if integer rounding is enabled. No need to round them again.
|
|
if expand_function_name != '_report_expand_unfoldable_line_with_groupby':
|
|
self._apply_integer_rounding_to_dynamic_lines(options, rslt)
|
|
|
|
if expansion_result.get('has_more'):
|
|
# We only add load_more line for groupby
|
|
next_offset = offset + expansion_result['offset_increment']
|
|
rslt.append(self._get_load_more_line(next_offset, line_dict_id, expand_function_name, groupby, expansion_result.get('progress', 0), options))
|
|
|
|
# In some specific cases, we may want to add lines that are always at the end. So they need to be added after the load more line.
|
|
if expansion_result.get('after_load_more_lines'):
|
|
rslt.extend(expansion_result['after_load_more_lines'])
|
|
|
|
return self._add_totals_below_sections(rslt, options)
|
|
|
|
def _add_totals_below_sections(self, lines, options):
|
|
""" Returns a new list, corresponding to lines with the required total lines added as sublines of the sections it contains.
|
|
"""
|
|
if not self.env.company.totals_below_sections or options.get('ignore_totals_below_sections'):
|
|
return lines
|
|
|
|
# Gather the lines needing the totals
|
|
lines_needing_total_below = set()
|
|
for line_dict in lines:
|
|
line_markup = self._get_markup(line_dict['id'])
|
|
|
|
if line_markup != 'total':
|
|
# If we are on the first level of an expandable line, we arelady generate its total
|
|
if line_dict.get('unfoldable') or (line_dict.get('unfolded') and line_dict.get('expand_function')):
|
|
lines_needing_total_below.add(line_dict['id'])
|
|
|
|
# All lines that are parent of other lines need to receive a total
|
|
line_parent_id = line_dict.get('parent_id')
|
|
if line_parent_id:
|
|
lines_needing_total_below.add(line_parent_id)
|
|
|
|
# Inject the totals
|
|
if lines_needing_total_below:
|
|
lines_with_totals_below = []
|
|
totals_below_stack = []
|
|
for line_dict in lines:
|
|
while totals_below_stack and not line_dict['id'].startswith(totals_below_stack[-1]['parent_id'] + LINE_ID_HIERARCHY_DELIMITER):
|
|
lines_with_totals_below.append(totals_below_stack.pop())
|
|
|
|
lines_with_totals_below.append(line_dict)
|
|
|
|
if line_dict['id'] in lines_needing_total_below and any(col.get('no_format') is not None for col in line_dict['columns']):
|
|
totals_below_stack.append(self._generate_total_below_section_line(line_dict))
|
|
|
|
while totals_below_stack:
|
|
lines_with_totals_below.append(totals_below_stack.pop())
|
|
|
|
return lines_with_totals_below
|
|
|
|
return lines
|
|
|
|
@api.model
|
|
def _get_load_more_line(self, offset, parent_line_id, expand_function_name, groupby, progress, options):
|
|
""" Returns a 'Load more' line allowing to reach the subsequent elements of an unfolded line with an expand function if the maximum
|
|
limit of sublines is reached (we load them by batch, using the load_more_limit field's value).
|
|
|
|
:param offset: The offset to be passed to the expand function to generate the next results, when clicking on this 'load more' line.
|
|
|
|
:param parent_line_id: The generic id of the line this load more line is created for.
|
|
|
|
:param expand_function_name: The name of the expand function this load_more is created for (so, the one of its parent).
|
|
|
|
:param progress: A json-formatted dict(column_group_key, value) containing the progress value for each column group, as it was
|
|
returned by the expand function. This is for example used by reports such as the general ledger, whose lines display a c
|
|
cumulative sum of their balance and the one of all the previous lines under the same parent. In this case, progress
|
|
will be the total sum of all the previous lines before the load_more line, that the subsequent lines will need to use as
|
|
base for their own cumulative sum.
|
|
|
|
:param options: The options dict corresponding to this report's state.
|
|
"""
|
|
return {
|
|
'id': self._get_generic_line_id(None, None, parent_line_id=parent_line_id, markup='load_more'),
|
|
'name': _("Load more..."),
|
|
'parent_id': parent_line_id,
|
|
'expand_function': expand_function_name,
|
|
'columns': [{} for col in options['columns']],
|
|
'unfoldable': False,
|
|
'unfolded': False,
|
|
'offset': offset,
|
|
'groupby': groupby, # We keep the groupby value from the parent, so that it can be propagated through js
|
|
'progress': progress,
|
|
}
|
|
|
|
def _report_expand_unfoldable_line_with_groupby(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None):
|
|
# The line we're expanding might be an inner groupby; we first need to find the report line generating it
|
|
report_line_id = None
|
|
for dummy, model, model_id in reversed(self._parse_line_id(line_dict_id)):
|
|
if model == 'account.report.line':
|
|
report_line_id = model_id
|
|
break
|
|
|
|
if report_line_id is None:
|
|
raise UserError(_("Trying to expand a group for a line which was not generated by a report line: %s", line_dict_id))
|
|
|
|
line = self.env['account.report.line'].browse(report_line_id)
|
|
|
|
if ',' not in groupby and options['export_mode'] is None:
|
|
# if ',' not in groupby, then its a terminal groupby (like 'id' in 'partner_id, id'), so we can use the 'load more' feature if necessary
|
|
# When printing, we want to ignore the limit.
|
|
limit_to_load = self.load_more_limit or None
|
|
else:
|
|
# Else, we disable it
|
|
limit_to_load = None
|
|
offset = 0
|
|
|
|
rslt_lines = line._expand_groupby(line_dict_id, groupby, options, offset=offset, limit=limit_to_load, load_one_more=bool(limit_to_load), unfold_all_batch_data=unfold_all_batch_data)
|
|
lines_to_load = rslt_lines[:self.load_more_limit] if limit_to_load else rslt_lines
|
|
|
|
if not limit_to_load and options['export_mode'] is None:
|
|
lines_to_load = self._regroup_lines_by_name_prefix(options, rslt_lines, '_report_expand_unfoldable_line_groupby_prefix_group', line.hierarchy_level,
|
|
groupby=groupby, parent_line_dict_id=line_dict_id)
|
|
|
|
return {
|
|
'lines': lines_to_load,
|
|
'offset_increment': len(lines_to_load),
|
|
'has_more': len(lines_to_load) < len(rslt_lines) if limit_to_load else False,
|
|
}
|
|
|
|
def _regroup_lines_by_name_prefix(self, options, lines_to_group, expand_function_name, parent_level, matched_prefix='', groupby=None, parent_line_dict_id=None):
|
|
""" Postprocesses a list of report line dictionaries in order to regroup them by name prefix and reduce the overall number of lines
|
|
if their number is above a provided threshold (set in the report configuration).
|
|
|
|
The lines regrouped under a common prefix will be removed from the returned list of lines; only the prefix line will stay, folded.
|
|
Its expand function must ensure the right sublines are reloaded when unfolding it.
|
|
|
|
:param options: Option dict for this report.
|
|
:lines_to_group: The lines list to regroup by prefix if necessary. They must all have the same parent line (which might be no line at all).
|
|
:expand_function_name: Name of the expand function to be called on created prefix group lines, when unfolding them
|
|
:parent_level: Level of the parent line, which generated the lines in lines_to_group. It will be used to compute the level of the prefix group lines.
|
|
:matched_prefix': A string containing the parent prefix that's already matched. For example, when computing prefix 'ABC', matched_prefix will be 'AB'.
|
|
:groupby: groupby value of the parent line, which generated the lines in lines_to_group.
|
|
:parent_line_dict_id: id of the parent line, which generated the lines in lines_to_group.
|
|
|
|
:return: lines_to_group, grouped by prefix if it was necessary.
|
|
"""
|
|
threshold = options['prefix_groups_threshold']
|
|
|
|
# When grouping by prefix, we ignore the totals
|
|
lines_to_group_without_totals = list(filter(lambda x: self._get_markup(x['id']) != 'total', lines_to_group))
|
|
|
|
if options['export_mode'] == 'print' or threshold <= 0 or len(lines_to_group_without_totals) < threshold:
|
|
# No grouping needs to be done
|
|
return lines_to_group
|
|
|
|
char_index = len(matched_prefix)
|
|
prefix_groups = defaultdict(list)
|
|
rslt = []
|
|
for line in lines_to_group_without_totals:
|
|
line_name = line['name'].strip()
|
|
|
|
if len(line_name) - 1 < char_index:
|
|
rslt.append(line)
|
|
else:
|
|
prefix_groups[line_name[char_index].lower()].append(line)
|
|
|
|
float_figure_types = {'monetary', 'integer', 'float'}
|
|
unfold_all = options['export_mode'] == 'print' or options.get('unfold_all')
|
|
for prefix_key, prefix_sublines in sorted(prefix_groups.items(), key=lambda x: x[0]):
|
|
# Compute the total of this prefix line, summming all of its content
|
|
prefix_expression_totals_by_group = {}
|
|
for column_index, column_data in enumerate(options['columns']):
|
|
if column_data['figure_type'] in float_figure_types:
|
|
# Then we want to sum this column's value in our children
|
|
for prefix_subline in prefix_sublines:
|
|
prefix_expr_label_result = prefix_expression_totals_by_group.setdefault(column_data['column_group_key'], {})
|
|
prefix_expr_label_result.setdefault(column_data['expression_label'], 0)
|
|
prefix_expr_label_result[column_data['expression_label']] += (prefix_subline['columns'][column_index]['no_format'] or 0)
|
|
|
|
column_values = []
|
|
for column in options['columns']:
|
|
col_value = prefix_expression_totals_by_group.get(column['column_group_key'], {}).get(column['expression_label'])
|
|
|
|
column_values.append(self._build_column_dict(col_value, column, options=options))
|
|
|
|
line_id = self._get_generic_line_id(None, None, parent_line_id=parent_line_dict_id, markup={'groupby_prefix_group': prefix_key})
|
|
|
|
sublines_nber = len(prefix_sublines)
|
|
prefix_to_display = prefix_key.upper()
|
|
|
|
if re.match(r'\s', prefix_to_display[-1]):
|
|
# In case the last character of the prefix to_display is blank, replace it by "[ ]", to make the space more visible to the user.
|
|
prefix_to_display = f'{prefix_to_display[:-1]}[ ]'
|
|
|
|
if sublines_nber == 1:
|
|
prefix_group_line_name = f"{matched_prefix}{prefix_to_display} " + _("(1 line)")
|
|
else:
|
|
prefix_group_line_name = f"{matched_prefix}{prefix_to_display} " + _("(%s lines)", sublines_nber)
|
|
|
|
prefix_group_line = {
|
|
'id': line_id,
|
|
'name': prefix_group_line_name,
|
|
'unfoldable': True,
|
|
'unfolded': unfold_all or line_id in options['unfolded_lines'],
|
|
'columns': column_values,
|
|
'groupby': groupby,
|
|
'level': parent_level + 1,
|
|
'parent_id': parent_line_dict_id,
|
|
'expand_function': expand_function_name,
|
|
'hide_line_buttons': True,
|
|
}
|
|
rslt.append(prefix_group_line)
|
|
|
|
return rslt
|
|
|
|
def _report_expand_unfoldable_line_groupby_prefix_group(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None):
|
|
""" Expand function used by prefix_group lines generated for groupby lines.
|
|
"""
|
|
report_line_id = None
|
|
parent_groupby_count = 0
|
|
for markup, model, model_id in reversed(self._parse_line_id(line_dict_id)):
|
|
if model == 'account.report.line':
|
|
report_line_id = model_id
|
|
break
|
|
elif isinstance(markup, dict) and 'groupby' in markup or 'groupby_prefix_group' in markup:
|
|
parent_groupby_count += 1
|
|
|
|
if report_line_id is None:
|
|
raise UserError(_("Trying to expand a group for a line which was not generated by a report line: %s", line_dict_id))
|
|
|
|
report_line = self.env['account.report.line'].browse(report_line_id)
|
|
|
|
|
|
matched_prefix = self._get_prefix_groups_matched_prefix_from_line_id(line_dict_id)
|
|
first_groupby = groupby.split(',')[0]
|
|
expand_options = {
|
|
**options,
|
|
'forced_domain': options.get('forced_domain', []) + [(f"{f'{first_groupby}.' if first_groupby != 'id' else ''}name", '=ilike', f'{matched_prefix}%')]
|
|
}
|
|
expanded_groupby_lines = report_line._expand_groupby(line_dict_id, groupby, expand_options)
|
|
parent_level = report_line.hierarchy_level + parent_groupby_count * 2
|
|
|
|
lines = self._regroup_lines_by_name_prefix(
|
|
options,
|
|
expanded_groupby_lines,
|
|
'_report_expand_unfoldable_line_groupby_prefix_group',
|
|
parent_level,
|
|
groupby=groupby,
|
|
matched_prefix=matched_prefix,
|
|
parent_line_dict_id=line_dict_id,
|
|
)
|
|
|
|
return {
|
|
'lines': lines,
|
|
'offset_increment': len(lines),
|
|
'has_more': False,
|
|
}
|
|
|
|
@api.model
|
|
def _get_prefix_groups_matched_prefix_from_line_id(self, line_dict_id):
|
|
matched_prefix = ''
|
|
for markup, dummy1, dummy2 in self._parse_line_id(line_dict_id):
|
|
if markup and isinstance(markup, dict) and 'groupby_prefix_group' in markup:
|
|
prefix_piece = markup['groupby_prefix_group']
|
|
matched_prefix += prefix_piece.upper()
|
|
else:
|
|
# Might happen if a groupby is grouped by prefix, then a subgroupby is grouped by another subprefix.
|
|
# In this case, we want to reset the prefix group to only consider the one used in the subgroupby.
|
|
matched_prefix = ''
|
|
|
|
return matched_prefix
|
|
|
|
@api.model
|
|
def format_value(self, options, value, figure_type, format_params=None):
|
|
if format_params is None:
|
|
format_params = {}
|
|
|
|
if 'currency' in format_params:
|
|
format_params['currency'] = self.env['res.currency'].browse(format_params['currency'].id)
|
|
|
|
return self._format_value(options=options, value=value, figure_type=figure_type, format_params=format_params)
|
|
|
|
def _format_value(self, options, value, figure_type, format_params=None):
|
|
""" Formats a value for display in a report (not especially numerical). figure_type provides the type of formatting we want.
|
|
"""
|
|
if value is None:
|
|
return ''
|
|
|
|
if figure_type == 'none':
|
|
return value
|
|
|
|
if isinstance(value, str) or figure_type == 'string':
|
|
return str(value)
|
|
|
|
if format_params is None:
|
|
format_params = {}
|
|
|
|
formatLang_params = {
|
|
'rounding_method': 'HALF-UP',
|
|
'rounding_unit': options.get('rounding_unit'),
|
|
}
|
|
|
|
if figure_type == 'monetary':
|
|
currency = self.env['res.currency'].browse(format_params['currency_id']) if 'currency_id' in format_params else self.env.company.currency_id
|
|
if options.get('multi_currency'):
|
|
formatLang_params['currency_obj'] = currency
|
|
else:
|
|
formatLang_params['digits'] = currency.decimal_places
|
|
|
|
elif figure_type == 'integer':
|
|
formatLang_params['digits'] = 0
|
|
|
|
elif figure_type == 'boolean':
|
|
return _("Yes") if bool(value) else _("No")
|
|
|
|
elif figure_type in ('date', 'datetime'):
|
|
return format_date(self.env, value)
|
|
|
|
else:
|
|
formatLang_params['digits'] = format_params.get('digits', 1)
|
|
|
|
if self._is_value_zero(value, figure_type, format_params):
|
|
# Make sure -0.0 becomes 0.0
|
|
value = abs(value)
|
|
|
|
if self.env.context.get('no_format'):
|
|
return value
|
|
|
|
formatted_amount = formatLang(self.env, value, **formatLang_params)
|
|
|
|
if figure_type == 'percentage':
|
|
return f"{formatted_amount}%"
|
|
|
|
return formatted_amount
|
|
|
|
@api.model
|
|
def _is_value_zero(self, amount, figure_type, format_params):
|
|
if amount is None:
|
|
return True
|
|
|
|
if figure_type == 'monetary':
|
|
currency = self.env['res.currency'].browse(format_params['currency_id']) if 'currency_id' in format_params else self.env.company.currency_id
|
|
return currency.is_zero(amount)
|
|
elif figure_type in NUMBER_FIGURE_TYPES:
|
|
return float_is_zero(amount, precision_digits=format_params.get('digits', 0))
|
|
else:
|
|
return False
|
|
|
|
def format_date(self, options, dt_filter='date'):
|
|
date_from = fields.Date.from_string(options[dt_filter]['date_from'])
|
|
date_to = fields.Date.from_string(options[dt_filter]['date_to'])
|
|
return self._get_dates_period(date_from, date_to, options['date']['mode'])['string']
|
|
|
|
def export_file(self, options, file_generator):
|
|
self.ensure_one()
|
|
|
|
export_options = {**options, 'export_mode': 'file'}
|
|
|
|
return {
|
|
'type': 'ir_actions_account_report_download',
|
|
'data': {
|
|
'options': json.dumps(export_options),
|
|
'file_generator': file_generator,
|
|
}
|
|
}
|
|
|
|
def _get_report_send_recipients(self, options):
|
|
custom_handler_model = self._get_custom_handler_model()
|
|
if custom_handler_model and hasattr(self.env[custom_handler_model], '_get_report_send_recipients'):
|
|
return self.env[custom_handler_model]._get_report_send_recipients(options)
|
|
return self.env['res.partner']
|
|
|
|
def export_to_pdf(self, options):
|
|
self.ensure_one()
|
|
|
|
base_url = self.env['ir.config_parameter'].sudo().get_param('report.url') or self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
|
rcontext = {
|
|
'mode': 'print',
|
|
'base_url': base_url,
|
|
'company': self.env.company,
|
|
}
|
|
|
|
print_options = self.get_options(previous_options={**options, 'export_mode': 'print'})
|
|
if print_options['sections']:
|
|
reports_to_print = self.env['account.report'].browse([section['id'] for section in print_options['sections']])
|
|
else:
|
|
reports_to_print = self
|
|
|
|
reports_options = []
|
|
for report in reports_to_print:
|
|
reports_options.append(report.get_options(previous_options={**print_options, 'selected_section_id': report.id}))
|
|
|
|
grouped_reports_by_format = groupby(
|
|
zip(reports_to_print, reports_options),
|
|
key=lambda report: len(report[1]['columns']) > 5 or report[1].get('horizontal_split')
|
|
)
|
|
|
|
footer = self.env['ir.actions.report']._render_template("fusion_accounting.internal_layout", values=rcontext)
|
|
footer = self.env['ir.actions.report']._render_template("web.minimal_layout", values=dict(rcontext, subst=True, body=markupsafe.Markup(footer.decode())))
|
|
|
|
action_report = self.env['ir.actions.report']
|
|
files_stream = []
|
|
for is_landscape, reports_with_options in grouped_reports_by_format:
|
|
bodies = []
|
|
|
|
for report, report_options in reports_with_options:
|
|
bodies.append(report._get_pdf_export_html(
|
|
report_options,
|
|
report._filter_out_folded_children(report._get_lines(report_options)),
|
|
additional_context={'base_url': base_url}
|
|
))
|
|
|
|
files_stream.append(
|
|
io.BytesIO(action_report._run_wkhtmltopdf(
|
|
bodies,
|
|
footer=footer.decode(),
|
|
landscape=is_landscape or self.env.context.get('force_landscape_printing'),
|
|
specific_paperformat_args={
|
|
'data-report-margin-top': 10,
|
|
'data-report-header-spacing': 10,
|
|
'data-report-margin-bottom': 15,
|
|
}
|
|
)
|
|
))
|
|
|
|
if len(files_stream) > 1:
|
|
result_stream = action_report._merge_pdfs(files_stream)
|
|
result = result_stream.getvalue()
|
|
# Close the different stream
|
|
result_stream.close()
|
|
for file_stream in files_stream:
|
|
file_stream.close()
|
|
else:
|
|
result = files_stream[0].read()
|
|
|
|
return {
|
|
'file_name': self.get_default_report_filename(options, 'pdf'),
|
|
'file_content': result,
|
|
'file_type': 'pdf',
|
|
}
|
|
|
|
def _get_pdf_export_html(self, options, lines, additional_context=None, template=None):
|
|
report_info = self.get_report_information(options)
|
|
|
|
custom_print_templates = report_info['custom_display'].get('pdf_export', {})
|
|
template = custom_print_templates.get('pdf_export_main', 'fusion_accounting.pdf_export_main')
|
|
|
|
render_values = {
|
|
'report': self,
|
|
'report_title': self.name,
|
|
'options': options,
|
|
'table_start': markupsafe.Markup('<tbody>'),
|
|
'table_end': markupsafe.Markup('''
|
|
</tbody></table></div>
|
|
<div style="page-break-after: always"></div>
|
|
<div class="d-flex align-items-start">
|
|
<table class="o_table">
|
|
'''),
|
|
'column_headers_render_data': self._get_column_headers_render_data(options),
|
|
'custom_templates': custom_print_templates,
|
|
}
|
|
if additional_context:
|
|
render_values.update(additional_context)
|
|
|
|
if options.get('order_column'):
|
|
lines = self.sort_lines(lines, options)
|
|
|
|
lines = self._format_lines_for_display(lines, options)
|
|
|
|
render_values['lines'] = lines
|
|
|
|
# Manage annotations.
|
|
render_values['annotations'] = self._build_annotations_list_for_pdf_export(options['date'], lines, report_info['annotations'])
|
|
|
|
options['css_custom_class'] = report_info['custom_display'].get('css_custom_class', '')
|
|
|
|
# Render.
|
|
return self.env['ir.qweb']._render(template, render_values)
|
|
|
|
def _build_annotations_list_for_pdf_export(self, date_options, lines, annotations_per_line_id):
|
|
annotations_to_render = []
|
|
number = 0
|
|
for line in lines:
|
|
if line_annotations := annotations_per_line_id.get(line['id']):
|
|
line['annotations'] = []
|
|
for annotation in line_annotations:
|
|
report_period_date_from = datetime.datetime.strptime(date_options['date_from'], '%Y-%m-%d').date()
|
|
report_period_date_to = datetime.datetime.strptime(date_options['date_to'], '%Y-%m-%d').date()
|
|
if not annotation['date'] or report_period_date_from <= annotation['date'] <= report_period_date_to:
|
|
number += 1
|
|
line['annotations'].append(str(number))
|
|
annotations_to_render.append({
|
|
'number': str(number),
|
|
'text': annotation['text'],
|
|
'date': format_date(self.env, annotation['date']) if annotation['date'] else None,
|
|
})
|
|
return annotations_to_render
|
|
|
|
def _filter_out_folded_children(self, lines):
|
|
""" Returns a list containing all the lines of the provided list that need to be displayed when printing,
|
|
hence removing the children whose parent is folded (especially useful to remove total lines).
|
|
"""
|
|
rslt = []
|
|
folded_lines = set()
|
|
for line in lines:
|
|
if line.get('unfoldable') and not line.get('unfolded'):
|
|
folded_lines.add(line['id'])
|
|
|
|
if 'parent_id' not in line or line['parent_id'] not in folded_lines:
|
|
rslt.append(line)
|
|
return rslt
|
|
|
|
def export_to_xlsx(self, options, response=None):
|
|
self.ensure_one()
|
|
output = io.BytesIO()
|
|
workbook = xlsxwriter.Workbook(output, {
|
|
'in_memory': True,
|
|
'strings_to_formulas': False,
|
|
})
|
|
|
|
print_options = self.get_options(previous_options={**options, 'export_mode': 'print'})
|
|
if print_options['sections']:
|
|
reports_to_print = self.env['account.report'].browse([section['id'] for section in print_options['sections']])
|
|
else:
|
|
reports_to_print = self
|
|
|
|
reports_options = []
|
|
for report in reports_to_print:
|
|
report_options = report.get_options(previous_options={**print_options, 'selected_section_id': report.id})
|
|
reports_options.append(report_options)
|
|
report._inject_report_into_xlsx_sheet(report_options, workbook, workbook.add_worksheet(report.name[:31]))
|
|
|
|
self._add_options_xlsx_sheet(workbook, reports_options)
|
|
|
|
workbook.close()
|
|
output.seek(0)
|
|
generated_file = output.read()
|
|
output.close()
|
|
|
|
return {
|
|
'file_name': self.get_default_report_filename(options, 'xlsx'),
|
|
'file_content': generated_file,
|
|
'file_type': 'xlsx',
|
|
}
|
|
|
|
@api.model
|
|
def _set_xlsx_cell_sizes(self, sheet, fonts, col, row, value, style, has_colspan):
|
|
""" This small helper will resize the cells if needed, to allow to get a better output. """
|
|
def get_string_width(font, string):
|
|
return font.getlength(string) / 5
|
|
|
|
# Get the correct font for the row style
|
|
font_type = ('Bol' if style.bold else 'Reg') + ('Ita' if style.italic else '')
|
|
report_font = fonts[font_type]
|
|
|
|
# 8.43 is the default width of a column in Excel.
|
|
if parse_version(xlsxwriter.__version__) >= parse_version('3.0.6'):
|
|
# cols_sizes was removed in 3.0.6 and colinfo was replaced by col_info
|
|
# see https://github.com/jmcnamara/XlsxWriter/commit/860f4a2404549aca1eccf9bf8361df95dc574f44
|
|
try:
|
|
col_width = sheet.col_info[col][0]
|
|
except KeyError:
|
|
col_width = 8.43
|
|
else:
|
|
col_width = sheet.col_sizes.get(col, [8.43])[0]
|
|
|
|
row_height = sheet.row_sizes.get(row, [8.43])[0]
|
|
|
|
if value is None:
|
|
value = ''
|
|
else:
|
|
try: # noqa: SIM105
|
|
# This is needed, otherwise we could compute width on very long number such as 12.0999999998
|
|
# which wouldn't show well in the end result as the numbers are rounded.
|
|
value = float_repr(float(value), self.env.company.currency_id.decimal_places)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Start by computing the width of the cell if we are not using colspans.
|
|
if not has_colspan:
|
|
# Ensure to take indents into account when computing the width.
|
|
formatted_value = f"{' ' * style.indent}{value}"
|
|
width = get_string_width(
|
|
report_font,
|
|
max(formatted_value.split('\n'), key=lambda line: get_string_width(report_font, line))
|
|
)
|
|
# We set the width if it is bigger than the current one, with a limit at 75 (max to avoid taking excessive space).
|
|
if width > col_width:
|
|
sheet.set_column(col, col, min(width + 4, 75)) # We need to add a little extra padding to ensure our columns are not clipping the text
|
|
|
|
def _inject_report_into_xlsx_sheet(self, options, workbook, sheet):
|
|
|
|
# We start by gathering the bold, italic and regular fonts to use later.
|
|
fonts = {}
|
|
for font_type in ('Reg', 'Bol', 'RegIta', 'BolIta'):
|
|
try:
|
|
lato_path = f'web/static/fonts/lato/Lato-{font_type}-webfont.ttf'
|
|
fonts[font_type] = ImageFont.truetype(file_path(lato_path), 12)
|
|
except (OSError, FileNotFoundError):
|
|
# This won't give great result, but it will work.
|
|
fonts[font_type] = ImageFont.load_default()
|
|
|
|
def write_cell(sheet, x, y, value, style, colspan=1, datetime=False):
|
|
self._set_xlsx_cell_sizes(sheet, fonts, x, y, value, style, colspan > 1)
|
|
if colspan == 1:
|
|
if datetime:
|
|
sheet.write_datetime(y, x, value, style)
|
|
else:
|
|
sheet.write(y, x, value, style)
|
|
else:
|
|
sheet.merge_range(y, x, y, x + colspan - 1, value, style)
|
|
|
|
date_default_col1_style = workbook.add_format({'font_name': 'Lato', 'align': 'left', 'font_size': 12, 'font_color': '#666666', 'indent': 2, 'num_format': 'yyyy-mm-dd'})
|
|
date_default_style = workbook.add_format({'font_name': 'Lato', 'align': 'left', 'font_size': 12, 'font_color': '#666666', 'num_format': 'yyyy-mm-dd'})
|
|
default_col1_style = workbook.add_format({'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666', 'indent': 2})
|
|
default_col2_style = workbook.add_format({'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666'})
|
|
default_style = workbook.add_format({'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666'})
|
|
annotation_style = workbook.add_format({'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666', 'text_wrap': True})
|
|
title_style = workbook.add_format({'font_name': 'Lato', 'font_size': 12, 'bold': True, 'bottom': 2})
|
|
level_0_style = workbook.add_format({'font_name': 'Lato', 'bold': True, 'font_size': 13, 'bottom': 6, 'font_color': '#666666'})
|
|
level_1_col1_style = workbook.add_format({'font_name': 'Lato', 'bold': True, 'font_size': 13, 'bottom': 1, 'font_color': '#666666', 'indent': 1})
|
|
level_1_col1_total_style = workbook.add_format({'font_name': 'Lato', 'bold': True, 'font_size': 13, 'bottom': 1, 'font_color': '#666666'})
|
|
level_1_col2_style = workbook.add_format({'font_name': 'Lato', 'bold': True, 'font_size': 13, 'bottom': 1, 'font_color': '#666666'})
|
|
level_1_style = workbook.add_format({'font_name': 'Lato', 'bold': True, 'font_size': 13, 'bottom': 1, 'font_color': '#666666'})
|
|
level_2_col1_style = workbook.add_format({'font_name': 'Lato', 'bold': True, 'font_size': 12, 'font_color': '#666666', 'indent': 2})
|
|
level_2_col1_total_style = workbook.add_format({'font_name': 'Lato', 'bold': True, 'font_size': 12, 'font_color': '#666666', 'indent': 1})
|
|
level_2_col2_style = workbook.add_format({'font_name': 'Lato', 'bold': True, 'font_size': 12, 'font_color': '#666666', 'indent': 1})
|
|
level_2_style = workbook.add_format({'font_name': 'Lato', 'bold': True, 'font_size': 12, 'font_color': '#666666'})
|
|
col1_styles = {}
|
|
|
|
print_mode_self = self.with_context(no_format=True)
|
|
lines = self._filter_out_folded_children(print_mode_self._get_lines(options))
|
|
annotations = self.get_annotations(options)
|
|
|
|
# For reports with lines generated for accounts, the account name and codes are shown in a single column.
|
|
# To help user post-process the report if they need, we should in such a case split the account name and code in two columns.
|
|
account_lines_split_names = {}
|
|
for line in lines:
|
|
line_model = self._get_model_info_from_id(line['id'])[0]
|
|
if line_model == 'account.account':
|
|
# Reuse the _split_code_name to split the name and code in two values.
|
|
account_lines_split_names[line['id']] = self.env['account.account']._split_code_name(line['name'])
|
|
|
|
original_x_offset = 1 if len(account_lines_split_names) > 0 else 0
|
|
|
|
y_offset = 0
|
|
# 1 and not 0 to leave space for the line name. original_x_offset allows making place for the code column if needed.
|
|
x_offset = original_x_offset + 1
|
|
|
|
# Add headers.
|
|
# For this, iterate in the same way as done in main_table_header template
|
|
column_headers_render_data = self._get_column_headers_render_data(options)
|
|
for header_level_index, header_level in enumerate(options['column_headers']):
|
|
for header_to_render in header_level * column_headers_render_data['level_repetitions'][header_level_index]:
|
|
colspan = header_to_render.get('colspan', column_headers_render_data['level_colspan'][header_level_index])
|
|
write_cell(sheet, x_offset, y_offset, header_to_render.get('name', ''), title_style, colspan + (1 if options['show_horizontal_group_total'] and header_level_index == 0 else 0))
|
|
x_offset += colspan
|
|
if options.get('column_percent_comparison') == 'growth':
|
|
write_cell(sheet, x_offset, y_offset, '%', title_style)
|
|
x_offset += 1
|
|
|
|
if options['show_horizontal_group_total'] and header_level_index != 0:
|
|
horizontal_group_name = next((group['name'] for group in options['available_horizontal_groups'] if group['id'] == options['selected_horizontal_group_id']), None)
|
|
write_cell(sheet, x_offset, y_offset, horizontal_group_name, title_style)
|
|
x_offset += 1
|
|
if annotations:
|
|
annotations_x_offset = x_offset
|
|
write_cell(sheet, annotations_x_offset, y_offset, 'Annotations', title_style)
|
|
x_offset += 1
|
|
y_offset += 1
|
|
x_offset = original_x_offset + 1
|
|
|
|
for subheader in column_headers_render_data['custom_subheaders']:
|
|
colspan = subheader.get('colspan', 1)
|
|
write_cell(sheet, x_offset, y_offset, subheader.get('name', ''), title_style, colspan)
|
|
x_offset += colspan
|
|
y_offset += 1
|
|
x_offset = original_x_offset + 1
|
|
|
|
if account_lines_split_names:
|
|
# If we have a separate account code column, add a title for it
|
|
write_cell(sheet, x_offset - 1, y_offset, _("Account Code"), title_style)
|
|
|
|
for column in options['columns']:
|
|
colspan = column.get('colspan', 1)
|
|
write_cell(sheet, x_offset, y_offset, column.get('name', ''), title_style, colspan)
|
|
x_offset += colspan
|
|
|
|
if options['show_horizontal_group_total']:
|
|
write_cell(sheet, x_offset, y_offset, options['columns'][0].get('name', ''), title_style, colspan)
|
|
|
|
if options.get('column_percent_comparison') == 'growth':
|
|
write_cell(sheet, x_offset, y_offset, '', title_style, colspan)
|
|
|
|
y_offset += 1
|
|
|
|
if options.get('order_column'):
|
|
lines = self.sort_lines(lines, options)
|
|
|
|
# Add lines.
|
|
counter = 1
|
|
for y in range(0, len(lines)):
|
|
level = lines[y].get('level')
|
|
is_total_line = 'total' in lines[y].get('class', '').split(' ')
|
|
if level == 0:
|
|
y_offset += 1
|
|
style = level_0_style
|
|
col1_style = style
|
|
col2_style = style
|
|
elif level == 1:
|
|
style = level_1_style
|
|
col1_style = level_1_col1_total_style if is_total_line else level_1_col1_style
|
|
col2_style = level_1_col2_style
|
|
elif level == 2:
|
|
style = level_2_style
|
|
col1_style = level_2_col1_total_style if is_total_line else level_2_col1_style
|
|
col2_style = level_2_col2_style
|
|
elif level and level >= 3:
|
|
style = default_style
|
|
col2_style = style
|
|
level_col1_styles = col1_styles.get(level)
|
|
if not level_col1_styles:
|
|
level_col1_styles = col1_styles[level] = {
|
|
'default': workbook.add_format(
|
|
{'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666', 'indent': level}
|
|
),
|
|
'total': workbook.add_format(
|
|
{
|
|
'font_name': 'Lato',
|
|
'bold': True,
|
|
'font_size': 12,
|
|
'font_color': '#666666',
|
|
'indent': level - 1,
|
|
}
|
|
),
|
|
}
|
|
col1_style = level_col1_styles['total'] if is_total_line else level_col1_styles['default']
|
|
else:
|
|
style = default_style
|
|
col1_style = default_col1_style
|
|
col2_style = default_col2_style
|
|
|
|
# write the first column, with a specific style to manage the indentation
|
|
x_offset = original_x_offset + 1
|
|
if lines[y]['id'] in account_lines_split_names:
|
|
code, name = account_lines_split_names[lines[y]['id']]
|
|
write_cell(sheet, 0, y + y_offset, name, col1_style)
|
|
write_cell(sheet, 1, y + y_offset, code, col2_style)
|
|
else:
|
|
cell_type, cell_value = self._get_cell_type_value(lines[y])
|
|
if cell_type == 'date':
|
|
write_cell(sheet, 0, y + y_offset, cell_value, date_default_col1_style, datetime=True)
|
|
else:
|
|
write_cell(sheet, 0, y + y_offset, cell_value, col1_style)
|
|
|
|
if lines[y].get('parent_id') and lines[y]['parent_id'] in account_lines_split_names:
|
|
write_cell(sheet, 1, y + y_offset, account_lines_split_names[lines[y]['parent_id']][0], col2_style)
|
|
elif account_lines_split_names:
|
|
write_cell(sheet, 1, y + y_offset, "", col2_style)
|
|
|
|
#write all the remaining cells
|
|
columns = lines[y]['columns']
|
|
if options.get('column_percent_comparison') and 'column_percent_comparison_data' in lines[y]:
|
|
columns += [lines[y].get('column_percent_comparison_data')]
|
|
|
|
if options['show_horizontal_group_total']:
|
|
columns += [lines[y].get('horizontal_group_total_data', {'name': 0})]
|
|
|
|
for x, column in enumerate(columns, start=x_offset):
|
|
cell_type, cell_value = self._get_cell_type_value(column)
|
|
if cell_type == 'date':
|
|
write_cell(sheet, x + lines[y].get('colspan', 1) - 1, y + y_offset, cell_value, date_default_style, datetime=True)
|
|
else:
|
|
write_cell(sheet, x + lines[y].get('colspan', 1) - 1, y + y_offset, cell_value, style)
|
|
|
|
# Write annotations.
|
|
if annotations and (line_annotations := annotations.get(lines[y]['id'])):
|
|
line_annotation_text = []
|
|
for line_annotation in line_annotations:
|
|
line_annotation_text.append(f"{counter} - {line_annotation['text']}")
|
|
counter += 1
|
|
write_cell(sheet, annotations_x_offset, y + y_offset, "\n".join(line_annotation_text), annotation_style)
|
|
|
|
def _add_options_xlsx_sheet(self, workbook, options_list):
|
|
"""Adds a new sheet for xlsx report exports with a summary of all filters and options activated at the moment of the export."""
|
|
filters_sheet = workbook.add_worksheet(_("Filters"))
|
|
# Set first and second column widths.
|
|
filters_sheet.set_column(0, 0, 20)
|
|
filters_sheet.set_column(1, 1, 50)
|
|
name_style = workbook.add_format({'font_name': 'Arial', 'bold': True, 'bottom': 2})
|
|
y_offset = 0
|
|
|
|
if len(options_list) == 1:
|
|
self.env['account.report'].browse(options_list[0]['report_id'])._inject_report_options_into_xlsx_sheet(options_list[0], filters_sheet, y_offset)
|
|
return
|
|
|
|
# Find uncommon keys
|
|
options_sets = list(map(set, options_list))
|
|
common_keys = set.intersection(*options_sets)
|
|
all_keys = set.union(*options_sets)
|
|
uncommon_options_keys = all_keys - common_keys
|
|
# Try to find the common filter values between all reports to avoid duplication.
|
|
common_options_values = {}
|
|
for key in common_keys:
|
|
first_value = options_list[0][key]
|
|
if all(options[key] == first_value for options in options_list[1:]):
|
|
common_options_values[key] = first_value
|
|
else:
|
|
uncommon_options_keys.add(key)
|
|
|
|
# Write common options to the sheet.
|
|
filters_sheet.write(y_offset, 0, _("All"), name_style)
|
|
y_offset += 1
|
|
y_offset = self._inject_report_options_into_xlsx_sheet(common_options_values, filters_sheet, y_offset)
|
|
|
|
for report_options in options_list:
|
|
report = self.env['account.report'].browse(report_options['report_id'])
|
|
|
|
filters_sheet.write(y_offset, 0, report.name, name_style)
|
|
y_offset += 1
|
|
new_offset = report._inject_report_options_into_xlsx_sheet(report_options, filters_sheet, y_offset, uncommon_options_keys)
|
|
|
|
if y_offset == new_offset:
|
|
y_offset -= 1
|
|
# Clear the report name's cell since it didn't add any data to the xlsx.
|
|
filters_sheet.write(y_offset, 0, " ")
|
|
else:
|
|
y_offset = new_offset
|
|
|
|
def _inject_report_options_into_xlsx_sheet(self, options, sheet, y_offset, options_to_print=None):
|
|
"""
|
|
Injects the report options into the filters sheet.
|
|
|
|
:param options: Dictionary containing report options.
|
|
:param sheet: XLSX sheet to inject options into.
|
|
:param y_offset: Offset for the vertical position in the sheet.
|
|
:param options_to_print: Optional list of names to print. If not provided, all printable options will be included.
|
|
"""
|
|
def write_filter_lines(filter_title, filter_lines, y_offset):
|
|
sheet.write(y_offset, 0, filter_title)
|
|
for line in filter_lines:
|
|
sheet.write(y_offset, 1, line)
|
|
y_offset += 1
|
|
return y_offset
|
|
|
|
def should_print_option(option_key):
|
|
"""Check if the option should be printed based on options_to_print."""
|
|
return not options_to_print or option_key in options_to_print
|
|
|
|
# Company
|
|
if should_print_option('companies'):
|
|
companies = options['companies']
|
|
title = _("Companies") if len(companies) > 1 else _("Company")
|
|
lines = [company['name'] for company in companies]
|
|
y_offset = write_filter_lines(title, lines, y_offset)
|
|
|
|
# Journals
|
|
if should_print_option('journals') and (journals := options.get('journals')):
|
|
journal_titles = [journal.get('title') for journal in journals if journal.get('selected')]
|
|
if journal_titles:
|
|
y_offset = write_filter_lines(_("Journals"), journal_titles, y_offset)
|
|
|
|
# Partners
|
|
if should_print_option('selected_partner_ids') and (partner_names := options.get('selected_partner_ids')):
|
|
y_offset = write_filter_lines(_("Partners"), partner_names, y_offset)
|
|
|
|
# Partner categories
|
|
if should_print_option('selected_partner_categories') and (partner_categories := options.get('selected_partner_categories')):
|
|
y_offset = write_filter_lines(_("Partner Categories"), partner_categories, y_offset)
|
|
|
|
# Horizontal groups
|
|
if should_print_option('selected_horizontal_group_id') and (group_id := options.get('selected_horizontal_group_id')):
|
|
for horizontal_group in options['available_horizontal_groups']:
|
|
if horizontal_group['id'] == group_id:
|
|
filter_name = horizontal_group['name']
|
|
y_offset = write_filter_lines(_("Horizontal Group"), [filter_name], y_offset)
|
|
break
|
|
|
|
# Currency
|
|
if should_print_option('company_currency') and options.get('company_currency'):
|
|
y_offset = write_filter_lines(_("Company Currency"), [options['company_currency']['currency_name']], y_offset)
|
|
|
|
# Filters
|
|
if should_print_option('aml_ir_filters'):
|
|
if options.get('aml_ir_filters') and any(opt['selected'] for opt in options['aml_ir_filters']):
|
|
filter_names = [opt['name'] for opt in options['aml_ir_filters'] if opt['selected']]
|
|
y_offset = write_filter_lines(_("Filters"), filter_names, y_offset)
|
|
|
|
# Extra options
|
|
# Array of tuples for the extra options: (name, option_key, condition)
|
|
extra_options = [
|
|
(_("With Draft Entries"), 'all_entries', self.filter_show_draft),
|
|
(_("Unreconciled Entries"), 'unreconciled', self.filter_unreconciled),
|
|
(_("Including Analytic Simulations"), 'include_analytic_without_aml', True)
|
|
]
|
|
filter_names = [
|
|
name for name, option_key, condition in extra_options
|
|
if (not options_to_print or option_key in options_to_print) and condition and options.get(option_key)
|
|
]
|
|
if filter_names:
|
|
y_offset = write_filter_lines(_("Options"), filter_names, y_offset)
|
|
|
|
return y_offset
|
|
|
|
def _get_cell_type_value(self, cell):
|
|
if 'date' not in cell.get('class', '') or not cell.get('name'):
|
|
# cell is not a date
|
|
return ('text', cell.get('name', ''))
|
|
if isinstance(cell['name'], (float, datetime.date, datetime.datetime)):
|
|
# the date is xlsx compatible
|
|
return ('date', cell['name'])
|
|
try:
|
|
# the date is parsable to a xlsx compatible date
|
|
lg = get_lang(self.env, self.env.user.lang)
|
|
return ('date', datetime.datetime.strptime(cell['name'], lg.date_format))
|
|
except:
|
|
# the date is not parsable thus is returned as text
|
|
return ('text', cell['name'])
|
|
|
|
def get_vat_for_export(self, options, raise_warning=True):
|
|
""" Returns the VAT number to use when exporting this report with the provided
|
|
options. If a single fiscal_position option is set, its VAT number will be
|
|
used; else the current company's will be, raising an error if its empty.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if self.filter_multi_company == 'tax_units' and options['tax_unit'] != 'company_only':
|
|
tax_unit = self.env['account.tax.unit'].browse(options['tax_unit'])
|
|
return tax_unit.vat
|
|
|
|
if options['fiscal_position'] in {'all', 'domestic'}:
|
|
company = self._get_sender_company_for_export(options)
|
|
if not company.vat and raise_warning:
|
|
action = self.env.ref('base.action_res_company_form')
|
|
raise RedirectWarning(_('No VAT number associated with your company. Please define one.'), action.id, _("Company Settings"))
|
|
return company.vat
|
|
|
|
fiscal_position = self.env['account.fiscal.position'].browse(options['fiscal_position'])
|
|
return fiscal_position.foreign_vat
|
|
|
|
@api.model
|
|
def get_report_company_ids(self, options):
|
|
""" Returns a list containing the ids of the companies to be used to
|
|
render this report, following the provided options.
|
|
"""
|
|
return [comp_data['id'] for comp_data in options['companies']]
|
|
|
|
def _get_partner_and_general_ledger_initial_balance_line(self, options, parent_line_id, eval_dict, account_currency=None, level_shift=0):
|
|
""" Helper to generate dynamic 'initial balance' lines, used by general ledger and partner ledger.
|
|
"""
|
|
line_columns = []
|
|
for column in options['columns']:
|
|
col_value = eval_dict[column['column_group_key']].get(column['expression_label'])
|
|
col_expr_label = column['expression_label']
|
|
|
|
if col_value is None or (col_expr_label == 'amount_currency' and not account_currency):
|
|
line_columns.append(self._build_column_dict(None, None))
|
|
else:
|
|
line_columns.append(self._build_column_dict(
|
|
col_value,
|
|
column,
|
|
options=options,
|
|
currency=account_currency if col_expr_label == 'amount_currency' else None,
|
|
))
|
|
|
|
# Display unfold & initial balance even when debit/credit column is hidden and the balance == 0
|
|
if not any(isinstance(column.get('no_format'), (int, float)) and column.get('expression_label') != 'balance' for column in line_columns):
|
|
return None
|
|
|
|
return {
|
|
'id': self._get_generic_line_id(None, None, parent_line_id=parent_line_id, markup='initial'),
|
|
'name': _("Initial Balance"),
|
|
'level': 3 + level_shift,
|
|
'parent_id': parent_line_id,
|
|
'columns': line_columns,
|
|
}
|
|
|
|
def _compute_column_percent_comparison_data(self, options, value1, value2, green_on_positive=True):
|
|
''' Helper to get the additional columns due to the growth comparison feature. When only one comparison is
|
|
requested, an additional column is there to show the percentage of growth based on the compared period.
|
|
:param options: The report options.
|
|
:param value1: The value in the current period.
|
|
:param value2: The value in the compared period.
|
|
:param green_on_positive: A flag customizing the value with a green color depending if the growth is positive.
|
|
:return: The new columns to add to line['columns'].
|
|
'''
|
|
if value1 is None or value2 is None or float_is_zero(value2, precision_rounding=0.1):
|
|
return {'name': _('n/a'), 'mode': 'muted'}
|
|
|
|
comparison_type = options['column_percent_comparison']
|
|
if comparison_type == 'growth':
|
|
|
|
values_diff = value1 - value2
|
|
growth = round(values_diff / value2 * 100, 1)
|
|
|
|
# In case the comparison is made on a negative figure, the color should be the other
|
|
# way around. For example:
|
|
# 2018 2017 %
|
|
# Product Sales 1000.00 -1000.00 -200.0%
|
|
#
|
|
# The percentage is negative, which is mathematically correct, but my sales increased
|
|
# => it should be green, not red!
|
|
if float_is_zero(growth, 1):
|
|
return {'name': '0.0%', 'mode': 'muted'}
|
|
else:
|
|
return {
|
|
'name': f"{float_repr(growth, 1)}%",
|
|
'mode': 'red' if ((values_diff > 0) ^ green_on_positive) else 'green',
|
|
}
|
|
|
|
elif comparison_type == 'budget':
|
|
percentage_value = value1 / value2 * 100
|
|
if float_is_zero(percentage_value, 1):
|
|
# To avoid negative 0
|
|
return {'name': '0.0%', 'mode': 'green'}
|
|
|
|
comparison_value = float_compare(value1, value2, 1)
|
|
return {
|
|
'name': f"{float_repr(percentage_value, 1)}%",
|
|
'mode': 'green' if (comparison_value >= 0 and green_on_positive) or (comparison_value == -1 and not green_on_positive) else 'red',
|
|
}
|
|
|
|
def _set_budget_column_comparisons(self, options, line):
|
|
"""
|
|
Set the percentage values in the budget columns
|
|
"""
|
|
for col_index, col in enumerate(line['columns']):
|
|
col_group_data = options['column_groups'][col['column_group_key']]
|
|
if 'budget_percentage' in col_group_data.get('forced_options'):
|
|
budget_id = col_group_data['forced_options']['budget_percentage']
|
|
date_key = col_group_data.get('forced_options', {}).get('date')
|
|
if not date_key:
|
|
continue
|
|
|
|
budget_base_col = None
|
|
budget_amount_col = None
|
|
for line_col in line['columns']:
|
|
other_col_group_key = line_col['column_group_key']
|
|
other_col_options = options['column_groups'][other_col_group_key]
|
|
if other_col_options.get('forced_options', {}).get('date') == date_key:
|
|
if other_col_options.get('forced_options', {}).get('budget_base') and line_col['figure_type'] == 'monetary':
|
|
budget_base_col = line_col
|
|
elif other_col_options.get('forced_options', {}).get('compute_budget') == budget_id:
|
|
budget_amount_col = line_col
|
|
|
|
value = self._compute_column_percent_comparison_data(
|
|
options,
|
|
budget_base_col['no_format'],
|
|
budget_amount_col['no_format'],
|
|
green_on_positive=budget_base_col['green_on_positive'],
|
|
)
|
|
comparison_column = self._build_column_dict(
|
|
value['name'],
|
|
{
|
|
**budget_amount_col,
|
|
'figure_type': 'string',
|
|
'comparison_mode': value['mode'],
|
|
}
|
|
)
|
|
line['columns'][col_index] = comparison_column
|
|
|
|
def _check_groupby_fields(self, groupby_fields_name: list[str] | str):
|
|
""" Checks that each string in the groupby_fields_name list is a valid groupby value for an accounting report (so: it must be a field from
|
|
account.move.line, or a custom value allowed by the _get_custom_groupby_map function of the custom handler).
|
|
"""
|
|
self.ensure_one()
|
|
if isinstance(groupby_fields_name, str | bool):
|
|
groupby_fields_name = groupby_fields_name.split(',') if groupby_fields_name else []
|
|
for field_name in (fname.strip() for fname in groupby_fields_name):
|
|
groupby_field = self.env['account.move.line']._fields.get(field_name)
|
|
custom_handler_name = self._get_custom_handler_model()
|
|
|
|
if groupby_field:
|
|
if not groupby_field.store:
|
|
raise UserError(_("Field %s of account.move.line is not stored, and hence cannot be used in a groupby expression", field_name))
|
|
elif custom_handler_name:
|
|
if field_name not in self.env[custom_handler_name]._get_custom_groupby_map():
|
|
raise UserError(_("Field %s does not exist on account.move.line, and is not supported by this report's custom handler.", field_name))
|
|
else:
|
|
raise UserError(_("Field %s does not exist on account.move.line.", field_name))
|
|
|
|
# ============ Accounts Coverage Debugging Tool - START ================
|
|
@api.depends('country_id', 'chart_template', 'root_report_id')
|
|
def _compute_is_account_coverage_report_available(self):
|
|
for report in self:
|
|
report.is_account_coverage_report_available = (
|
|
(
|
|
self.availability_condition == 'country' and self.env.company.account_fiscal_country_id == self.country_id
|
|
or
|
|
self.availability_condition == 'coa' and self.env.company.chart_template == self.chart_template
|
|
or
|
|
self.availability_condition == 'always'
|
|
)
|
|
and
|
|
self.root_report_id in (
|
|
self.env.ref('fusion_accounting.profit_and_loss', raise_if_not_found=False),
|
|
self.env.ref('fusion_accounting.balance_sheet', raise_if_not_found=False)
|
|
)
|
|
)
|
|
|
|
def action_download_xlsx_accounts_coverage_report(self):
|
|
"""
|
|
Generate an XLSX file that can be used to debug the
|
|
report by issuing the following warnings if applicable:
|
|
- an account exists in the Chart of Accounts but is not mentioned in any line of the report (red)
|
|
- an account is reported in multiple lines of the report (orange)
|
|
- an account is reported in a line of the report but does not exist in the Chart of Accounts (yellow)
|
|
"""
|
|
self.ensure_one()
|
|
if not self.is_account_coverage_report_available:
|
|
raise UserError(_("The Accounts Coverage Report is not available for this report."))
|
|
|
|
output = io.BytesIO()
|
|
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
|
|
worksheet = workbook.add_worksheet(_('Accounts coverage'))
|
|
worksheet.set_column(0, 0, 20)
|
|
worksheet.set_column(1, 1, 75)
|
|
worksheet.set_column(2, 2, 80)
|
|
worksheet.freeze_panes(1, 0)
|
|
|
|
headers = [_("Account Code / Tag"), _("Error message"), _("Report lines mentioning the account code"), '#FFFFFF']
|
|
lines = [headers] + self._generate_accounts_coverage_report_xlsx_lines()
|
|
for i, line in enumerate(lines):
|
|
worksheet.write_row(i, 0, line[:-1], workbook.add_format({'bg_color': line[-1]}))
|
|
|
|
workbook.close()
|
|
attachment_id = self.env['ir.attachment'].create({
|
|
'name': f"{self.display_name} - {_('Accounts Coverage Report')}",
|
|
'datas': base64.encodebytes(output.getvalue())
|
|
})
|
|
return {
|
|
"type": "ir.actions.act_url",
|
|
"url": f"/web/content/{attachment_id.id}",
|
|
"target": "download",
|
|
}
|
|
|
|
def _generate_accounts_coverage_report_xlsx_lines(self):
|
|
"""
|
|
Generate the lines of the XLSX file that can be used to debug the
|
|
report by issuing the following warnings if applicable:
|
|
- an account exists in the Chart of Accounts but is not mentioned in any line of the report (red)
|
|
- an account is reported in multiple lines of the report (orange)
|
|
- an account is reported in a line of the report but does not exist in the Chart of Accounts (yellow)
|
|
"""
|
|
def get_account_domain(prefix):
|
|
# Helper function to get the right domain to find the account
|
|
# This function verifies if we have to look for a tag or if we have
|
|
# to look for an account code.
|
|
if tag_matching := ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(prefix):
|
|
if tag_matching['ref']:
|
|
account_tag_id = self.env['ir.model.data']._xmlid_to_res_id(tag_matching['ref'])
|
|
else:
|
|
account_tag_id = int(tag_matching['id'])
|
|
return 'tag_ids', 'in', (account_tag_id,)
|
|
else:
|
|
return 'code', '=like', f'{prefix}%'
|
|
|
|
self.ensure_one()
|
|
|
|
all_reported_accounts = self.env["account.account"] # All accounts mentioned in the report (including those reported without using the account code)
|
|
accounts_by_expressions = {} # {expression_id: account.account objects}
|
|
reported_account_codes = [] # [{'prefix': ..., 'balance': ..., 'exclude': ..., 'line': ...}, ...]
|
|
non_existing_codes = defaultdict(lambda: self.env["account.report.line"]) # {non_existing_account_code: {lines_with_that_code,}}
|
|
lines_per_non_linked_tag = defaultdict(lambda: self.env['account.report.line'])
|
|
lines_using_bad_operator_per_tag = defaultdict(lambda: self.env['account.report.line'])
|
|
candidate_duplicate_codes = defaultdict(lambda: self.env["account.report.line"]) # {candidate_duplicate_account_code: {lines_with_that_code,}}
|
|
duplicate_codes = defaultdict(lambda: self.env["account.report.line"]) # {verified duplicate_account_code: {lines_with_that_code,}}
|
|
duplicate_codes_same_line = defaultdict(lambda: self.env["account.report.line"]) # {duplicate_account_code: {line_with_that_code_multiple_times,}}
|
|
common_account_domain = [
|
|
*self.env['account.account']._check_company_domain(self.env.company),
|
|
]
|
|
|
|
# tag_ids already linked to an account - avoid several search_count to know if the tag is used or not
|
|
tag_ids_linked_to_account = set(self.env['account.account'].search([('tag_ids', '!=', False)]).tag_ids.ids)
|
|
|
|
expressions = self.line_ids.expression_ids._expand_aggregations()
|
|
for i, expr in enumerate(expressions):
|
|
reported_accounts = self.env["account.account"]
|
|
if expr.engine == "domain":
|
|
domain = literal_eval(expr.formula.strip())
|
|
accounts_domain = []
|
|
for j, operand in enumerate(domain):
|
|
if isinstance(operand, tuple):
|
|
operand = list(operand)
|
|
# Skip tuples that will not be used in the new domain to retrieve the reported accounts
|
|
if not operand[0].startswith('account_id.'):
|
|
if domain[j - 1] in ("&", "|", "!"): # Remove the operator linked to the tuple if it exists
|
|
accounts_domain.pop()
|
|
continue
|
|
operand[0] = operand[0].replace('account_id.', '')
|
|
# Check that the code exists in the CoA
|
|
if operand[0] == 'code' and not self.env["account.account"].search_count([operand]):
|
|
non_existing_codes[operand[2]] |= expr.report_line_id
|
|
elif operand[0] == 'tag_ids':
|
|
tag_ids = operand[2]
|
|
if not isinstance(tag_ids, (list, tuple, set)):
|
|
tag_ids = [tag_ids]
|
|
|
|
if operand[1] in ('=', 'in'):
|
|
tag_ids_to_browse = [tag_id for tag_id in tag_ids if tag_id not in tag_ids_linked_to_account]
|
|
for tag in self.env['account.account.tag'].browse(tag_ids_to_browse):
|
|
lines_per_non_linked_tag[f'{tag.name} ({tag.id})'] |= expr.report_line_id
|
|
else:
|
|
for tag in self.env['account.account.tag'].browse(tag_ids):
|
|
lines_using_bad_operator_per_tag[f'{tag.name} ({tag.id}) - Operator: {operand[1]}'] |= expr.report_line_id
|
|
|
|
accounts_domain.append(operand)
|
|
reported_accounts += self.env['account.account'].search(accounts_domain)
|
|
elif expr.engine == "account_codes":
|
|
account_codes = []
|
|
for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(expr.formula.replace(' ', '')):
|
|
if not token:
|
|
continue
|
|
token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token)
|
|
if not token_match:
|
|
continue
|
|
|
|
parsed_token = token_match.groupdict()
|
|
account_codes.append({
|
|
'prefix': parsed_token['prefix'],
|
|
'balance': parsed_token['balance_character'],
|
|
'exclude': parsed_token['excluded_prefixes'].split(',') if parsed_token['excluded_prefixes'] else [],
|
|
'line': expr.report_line_id,
|
|
})
|
|
|
|
for account_code in account_codes:
|
|
reported_account_codes.append(account_code)
|
|
exclude_domain_accounts = [get_account_domain(exclude_code) for exclude_code in account_code['exclude']]
|
|
reported_accounts += self.env["account.account"].search([
|
|
*common_account_domain,
|
|
get_account_domain(account_code['prefix']),
|
|
*[excl_domain for excl_tuple in exclude_domain_accounts for excl_domain in ("!", excl_tuple)],
|
|
])
|
|
|
|
# Check that the code exists in the CoA or that the tag is linked to an account
|
|
prefixes_to_check = [account_code['prefix']] + account_code['exclude']
|
|
for prefix_to_check in prefixes_to_check:
|
|
account_domain = get_account_domain(prefix_to_check)
|
|
if not self.env["account.account"].search_count([
|
|
*common_account_domain,
|
|
account_domain,
|
|
]):
|
|
# Identify if we're working with account codes or account tags
|
|
if account_domain[0] == 'code':
|
|
non_existing_codes[prefix_to_check] |= account_code['line']
|
|
elif account_domain[0] == 'tag_ids':
|
|
lines_per_non_linked_tag[prefix_to_check] |= account_code['line']
|
|
|
|
all_reported_accounts |= reported_accounts
|
|
accounts_by_expressions[expr.id] = reported_accounts
|
|
|
|
# Check if an account is reported multiple times in the same line of the report
|
|
if len(reported_accounts) != len(set(reported_accounts)):
|
|
seen = set()
|
|
for reported_account in reported_accounts:
|
|
if reported_account not in seen:
|
|
seen.add(reported_account)
|
|
else:
|
|
duplicate_codes_same_line[reported_account.code] |= expr.report_line_id
|
|
|
|
# Check if the account is reported in multiple lines of the report
|
|
for expr2 in expressions[:i + 1]:
|
|
reported_accounts2 = accounts_by_expressions[expr2.id]
|
|
for duplicate_account in (reported_accounts & reported_accounts2):
|
|
if len(expr.report_line_id | expr2.report_line_id) > 1 \
|
|
and expr.date_scope == expr2.date_scope \
|
|
and expr.subformula == expr2.subformula:
|
|
candidate_duplicate_codes[duplicate_account.code] |= expr.report_line_id | expr2.report_line_id
|
|
|
|
# Check that the duplicates are not false positives because of the balance character
|
|
for candidate_duplicate_code, candidate_duplicate_lines in candidate_duplicate_codes.items():
|
|
seen_balance_chars = []
|
|
for reported_account_code in reported_account_codes:
|
|
if candidate_duplicate_code.startswith(reported_account_code['prefix']) and reported_account_code['balance']:
|
|
seen_balance_chars.append(reported_account_code['balance'])
|
|
if not seen_balance_chars or seen_balance_chars.count("C") > 1 or seen_balance_chars.count("D") > 1:
|
|
duplicate_codes[candidate_duplicate_code] |= candidate_duplicate_lines
|
|
|
|
# Check that all codes in CoA are correctly reported
|
|
if self.root_report_id == self.env.ref('fusion_accounting.profit_and_loss'):
|
|
accounts_in_coa = self.env["account.account"].search([
|
|
*common_account_domain,
|
|
('account_type', 'in', ("income", "income_other", "expense", "expense_depreciation", "expense_direct_cost")),
|
|
('account_type', '!=', "off_balance"),
|
|
])
|
|
else: # Balance Sheet
|
|
accounts_in_coa = self.env["account.account"].search([
|
|
*common_account_domain,
|
|
('account_type', 'not in', ("off_balance", "income", "income_other", "expense", "expense_depreciation", "expense_direct_cost"))
|
|
])
|
|
|
|
# Compute codes that exist in the CoA but are not reported in the report
|
|
non_reported_codes = set((accounts_in_coa - all_reported_accounts).mapped('code'))
|
|
|
|
# Create the lines that will be displayed in the xlsx
|
|
all_reported_codes = sorted(set(all_reported_accounts.mapped("code")) | non_reported_codes | non_existing_codes.keys())
|
|
errors_trie = self._get_accounts_coverage_report_errors_trie(all_reported_codes, non_reported_codes, duplicate_codes, duplicate_codes_same_line, non_existing_codes)
|
|
errors_trie['children'].update(**self._get_account_tag_coverage_report_errors_trie(lines_per_non_linked_tag, lines_using_bad_operator_per_tag)) # Add tags that are not linked to an account
|
|
|
|
errors_trie = self._regroup_accounts_coverage_report_errors_trie(errors_trie)
|
|
return self._get_accounts_coverage_report_coverage_lines("", errors_trie)
|
|
|
|
def _get_accounts_coverage_report_errors_trie(self, all_reported_codes, non_reported_codes, duplicate_codes, duplicate_codes_same_line, non_existing_codes):
|
|
"""
|
|
Create the trie that will be used to regroup the same errors on the same subcodes.
|
|
This trie will be in the form of:
|
|
{
|
|
"children": {
|
|
"1": {
|
|
"children": {
|
|
"10": { ... },
|
|
"11": { ... },
|
|
},
|
|
"lines": {
|
|
"Line1",
|
|
"Line2",
|
|
},
|
|
"errors": {
|
|
"DUPLICATE"
|
|
}
|
|
},
|
|
"lines": {
|
|
"",
|
|
},
|
|
"errors": {
|
|
None # Avoid that all codes are merged into the root with the code "" in case all of the errors are the same
|
|
},
|
|
}
|
|
"""
|
|
errors_trie = {"children": {}, "lines": {}, "errors": {None}}
|
|
for reported_code in all_reported_codes:
|
|
current_trie = errors_trie
|
|
lines = self.env["account.report.line"]
|
|
errors = set()
|
|
if reported_code in non_reported_codes:
|
|
errors.add("NON_REPORTED")
|
|
elif reported_code in duplicate_codes_same_line:
|
|
lines |= duplicate_codes_same_line[reported_code]
|
|
errors.add("DUPLICATE_SAME_LINE")
|
|
elif reported_code in duplicate_codes:
|
|
lines |= duplicate_codes[reported_code]
|
|
errors.add("DUPLICATE")
|
|
elif reported_code in non_existing_codes:
|
|
lines |= non_existing_codes[reported_code]
|
|
errors.add("NON_EXISTING")
|
|
else:
|
|
errors.add("NONE")
|
|
|
|
for j in range(1, len(reported_code) + 1):
|
|
current_trie = current_trie["children"].setdefault(reported_code[:j], {
|
|
"children": {},
|
|
"lines": lines,
|
|
"errors": errors
|
|
})
|
|
return errors_trie
|
|
|
|
@api.model
|
|
def _get_account_tag_coverage_report_errors_trie(self, lines_per_non_linked_tag, lines_per_bad_operator_tag):
|
|
""" As we don't want to make a hierarchy for tags, we use a specific
|
|
function to handle tags.
|
|
"""
|
|
errors = {
|
|
non_linked_tag: {
|
|
'children': {},
|
|
'lines': line,
|
|
'errors': {'NON_LINKED'},
|
|
}
|
|
for non_linked_tag, line in lines_per_non_linked_tag.items()
|
|
}
|
|
errors.update({
|
|
bad_operator_tag: {
|
|
'children': {},
|
|
'lines': line,
|
|
'errors': {'BAD_OPERATOR'},
|
|
}
|
|
for bad_operator_tag, line in lines_per_bad_operator_tag.items()
|
|
})
|
|
return errors
|
|
|
|
def _regroup_accounts_coverage_report_errors_trie(self, trie):
|
|
"""
|
|
Regroup the codes that have the same error under the same common subcode/prefix.
|
|
This is done in-place on the given trie.
|
|
"""
|
|
if trie.get("children"):
|
|
children_errors = set()
|
|
children_lines = self.env["account.report.line"]
|
|
if trie.get("errors"): # Add own error
|
|
children_errors |= set(trie.get("errors"))
|
|
for child in trie["children"].values():
|
|
regroup = self._regroup_accounts_coverage_report_errors_trie(child)
|
|
children_lines |= regroup["lines"]
|
|
children_errors |= set(regroup["errors"])
|
|
if len(children_errors) == 1 and children_lines and children_lines == trie["lines"]:
|
|
trie["children"] = {}
|
|
trie["lines"] = children_lines
|
|
trie["errors"] = children_errors
|
|
return trie
|
|
|
|
def _get_accounts_coverage_report_coverage_lines(self, subcode, trie, coverage_lines=None):
|
|
"""
|
|
Create the coverage lines from the grouped trie. Each line has
|
|
- the account code
|
|
- the error message
|
|
- the lines on which the account code is used
|
|
- the color of the error message for the xlsx
|
|
"""
|
|
# Dictionnary of the three possible errors, their message and the corresponding color for the xlsx file
|
|
ERRORS = {
|
|
"NON_REPORTED": {
|
|
"msg": _("This account exists in the Chart of Accounts but is not mentioned in any line of the report"),
|
|
"color": "#FF0000"
|
|
},
|
|
"DUPLICATE": {
|
|
"msg": _("This account is reported in multiple lines of the report"),
|
|
"color": "#FF8916"
|
|
},
|
|
"DUPLICATE_SAME_LINE": {
|
|
"msg": _("This account is reported multiple times on the same line of the report"),
|
|
"color": "#E6A91D"
|
|
},
|
|
"NON_EXISTING": {
|
|
"msg": _("This account is reported in a line of the report but does not exist in the Chart of Accounts"),
|
|
"color": "#FFBF00"
|
|
},
|
|
"NON_LINKED": {
|
|
"msg": _("This tag is reported in a line of the report but is not linked to any account of the Chart of Accounts"),
|
|
"color": "#FFBF00",
|
|
},
|
|
"BAD_OPERATOR": {
|
|
"msg": _("The used operator is not supported for this expression."),
|
|
"color": "#FFBF00",
|
|
}
|
|
}
|
|
if coverage_lines is None:
|
|
coverage_lines = []
|
|
if trie.get("children"):
|
|
for child in trie.get("children"):
|
|
self._get_accounts_coverage_report_coverage_lines(child, trie["children"][child], coverage_lines)
|
|
else:
|
|
error = list(trie["errors"])[0] if trie["errors"] else False
|
|
if error and error != "NONE":
|
|
coverage_lines.append([
|
|
subcode,
|
|
ERRORS[error]["msg"],
|
|
" + ".join(trie["lines"].sorted().mapped("name")),
|
|
ERRORS[error]["color"]
|
|
])
|
|
return coverage_lines
|
|
|
|
# ============ Accounts Coverage Debugging Tool - END ================
|
|
|
|
def _generate_file_data_with_error_check(self, options, content_generator, generator_params, errors):
|
|
""" Checks for critical errors (i.e. errors that would cause the rendering to fail) in the generator values.
|
|
If at least one error is critical, the 'account.report.file.download.error.wizard' wizard is opened
|
|
before rendering the file, so they can be fixed.
|
|
If there are only non-critical errors, the wizard is opened after the file has been generated,
|
|
allowing the user to download it anyway.
|
|
|
|
:param dict options: The report options.
|
|
:param def content_generator: The function used to generate the exported content.
|
|
:param dict generator_params: The parameters passed to the 'content_generator' method (List).
|
|
:param list errors: A list of errors in the following format:
|
|
[
|
|
{
|
|
'message': The error message to be displayed in the wizard (String),
|
|
'action_text': The text of the action button (String),
|
|
'action': Contains the action values (Dictionary),
|
|
'level': One of 'info', 'warning', 'danger'. (String).
|
|
Only the 'danger' level represents a blocking error.
|
|
},
|
|
{...},
|
|
]
|
|
:returns: The data that will be used by the file generator.
|
|
:rtype: dict
|
|
"""
|
|
if errors is None:
|
|
errors = []
|
|
self.ensure_one()
|
|
if any(error_value.get('level') == 'danger' for error_value in errors.values()):
|
|
raise AccountReportFileDownloadException(errors)
|
|
|
|
content = content_generator(**generator_params)
|
|
|
|
file_data = {
|
|
'file_name': self.get_default_report_filename(options, generator_params['file_type']),
|
|
'file_content': re.sub(r'\n\s*\n', '\n', content).encode(),
|
|
'file_type': generator_params['file_type'],
|
|
}
|
|
|
|
if errors:
|
|
raise AccountReportFileDownloadException(errors, file_data)
|
|
|
|
return file_data
|
|
|
|
def action_create_composite_report(self):
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.report',
|
|
'views': [[False, 'form']],
|
|
'context': {
|
|
'default_section_report_ids': self.ids,
|
|
}
|
|
}
|
|
|
|
def show_error_branch_allowed(self, *args, **kwargs):
|
|
raise UserError(_("Please select the main company and its branches in the company selector to proceed."))
|
|
|
|
|
|
class AccountReportLine(models.Model):
|
|
_inherit = 'account.report.line'
|
|
|
|
display_custom_groupby_warning = fields.Boolean(compute='_compute_display_custom_groupby_warning')
|
|
|
|
@api.depends('groupby', 'user_groupby')
|
|
def _compute_display_custom_groupby_warning(self):
|
|
for line in self:
|
|
line.display_custom_groupby_warning = line.get_external_id() and line.user_groupby != line.groupby
|
|
|
|
@api.constrains('groupby', 'user_groupby')
|
|
def _validate_groupby(self):
|
|
super()._validate_groupby()
|
|
for report_line in self:
|
|
report_line.report_id._check_groupby_fields(report_line.user_groupby)
|
|
report_line.report_id._check_groupby_fields(report_line.groupby)
|
|
|
|
def _expand_groupby(self, line_dict_id, groupby, options, offset=0, limit=None, load_one_more=False, unfold_all_batch_data=None):
|
|
""" Expand function used to get the sublines of a groupby.
|
|
groupby param is a string consisting of one or more coma-separated field names. Only the first one
|
|
will be used for the expansion; if there are subsequent ones, the generated lines will themselves used them as
|
|
their groupby value, and point to this expand_function, hence generating a hierarchy of groupby).
|
|
"""
|
|
self.ensure_one()
|
|
|
|
group_indent = 0
|
|
line_id_list = self.report_id._parse_line_id(line_dict_id)
|
|
|
|
# Parse groupby
|
|
groupby_data = self._parse_groupby(options, groupby_to_expand=groupby)
|
|
groupby_model = groupby_data['current_groupby_model']
|
|
next_groupby = groupby_data['next_groupby']
|
|
current_groupby = groupby_data['current_groupby']
|
|
custom_groupby_map = groupby_data['custom_groupby_map']
|
|
|
|
# If this line is a sub-groupby of groupby line (for example, when grouping by partner, id; the id line is a subgroup of partner),
|
|
# we need to add the domain of the parent groupby criteria to the options
|
|
prefix_groups_count = 0
|
|
sub_groupby_domain = []
|
|
full_sub_groupby_key_elements = []
|
|
for markup, model, value in line_id_list:
|
|
if isinstance(markup, dict) and 'groupby' in markup:
|
|
field_name = markup['groupby']
|
|
if field_name in custom_groupby_map:
|
|
sub_groupby_domain += custom_groupby_map[field_name]['domain_builder'](value)
|
|
else:
|
|
sub_groupby_domain.append((field_name, '=', value))
|
|
full_sub_groupby_key_elements.append(f"{field_name}:{value}")
|
|
elif isinstance(markup, dict) and 'groupby_prefix_group' in markup:
|
|
prefix_groups_count += 1
|
|
|
|
if model == 'account.group':
|
|
group_indent += 1
|
|
|
|
if sub_groupby_domain:
|
|
forced_domain = options.get('forced_domain', []) + sub_groupby_domain
|
|
options = {**options, 'forced_domain': forced_domain}
|
|
|
|
# If the report transmitted custom_unfold_all_batch_data dictionary, use it
|
|
full_sub_groupby_key = f"[{self.id}]{','.join(full_sub_groupby_key_elements)}=>{current_groupby}"
|
|
|
|
cached_result = (unfold_all_batch_data or {}).get(full_sub_groupby_key)
|
|
|
|
if cached_result is not None:
|
|
all_column_groups_expression_totals = cached_result
|
|
else:
|
|
all_column_groups_expression_totals = self.report_id._compute_expression_totals_for_each_column_group(
|
|
self.expression_ids,
|
|
options,
|
|
groupby_to_expand=groupby,
|
|
offset=offset,
|
|
limit=limit + 1 if limit and load_one_more else limit,
|
|
)
|
|
|
|
# Put similar grouping keys from different totals/periods together, so that we don't display multiple
|
|
# lines for the same grouping key
|
|
|
|
figure_types_defaulting_to_0 = {'monetary', 'percentage', 'integer', 'float'}
|
|
|
|
default_value_per_expr_label = {
|
|
col_opt['expression_label']: 0 if col_opt['figure_type'] in figure_types_defaulting_to_0 else None
|
|
for col_opt in options['columns']
|
|
}
|
|
|
|
# Gather default value for each expression, in case it has no value for a given grouping key
|
|
default_value_per_expression = {}
|
|
for expression in self.expression_ids:
|
|
if expression.figure_type:
|
|
default_value = 0 if expression.figure_type in figure_types_defaulting_to_0 else None
|
|
else:
|
|
default_value = default_value_per_expr_label.get(expression.label)
|
|
|
|
default_value_per_expression[expression] = {'value': default_value}
|
|
|
|
# Build each group's result
|
|
aggregated_group_totals = defaultdict(lambda: defaultdict(default_value_per_expression.copy))
|
|
for column_group_key, expression_totals in all_column_groups_expression_totals.items():
|
|
for expression in self.expression_ids:
|
|
for grouping_key, result in expression_totals[expression]['value']:
|
|
aggregated_group_totals[grouping_key][column_group_key][expression] = {'value': result}
|
|
|
|
# Generate groupby lines
|
|
group_lines_by_keys = {}
|
|
for grouping_key, group_totals in aggregated_group_totals.items():
|
|
# For this, we emulate a dict formatted like the result of _compute_expression_totals_for_each_column_group, so that we can call
|
|
# _build_static_line_columns like on non-grouped lines
|
|
line_id = self.report_id._get_generic_line_id(groupby_model, grouping_key, parent_line_id=line_dict_id, markup={'groupby': current_groupby})
|
|
group_line_dict = {
|
|
# 'name' key will be set later, so that we can browse all the records of this expansion at once (in case we're dealing with records)
|
|
'id': line_id,
|
|
'unfoldable': bool(next_groupby),
|
|
'unfolded': (next_groupby and options['unfold_all']) or line_id in options['unfolded_lines'],
|
|
'groupby': next_groupby,
|
|
'columns': self.report_id._build_static_line_columns(self, options, group_totals, groupby_model=groupby_model),
|
|
'level': self.hierarchy_level + 2 * (prefix_groups_count + len(sub_groupby_domain) + 1) + (group_indent - 1),
|
|
'parent_id': line_dict_id,
|
|
'expand_function': '_report_expand_unfoldable_line_with_groupby' if next_groupby else None,
|
|
'caret_options': groupby_model if not next_groupby else None,
|
|
}
|
|
|
|
if self.report_id.custom_handler_model_id:
|
|
self.env[self.report_id.custom_handler_model_name]._custom_groupby_line_completer(self.report_id, options, group_line_dict)
|
|
|
|
# Growth comparison column.
|
|
if options.get('column_percent_comparison') == 'growth':
|
|
compared_expression = self.expression_ids.filtered(lambda expr: expr.label == group_line_dict['columns'][0]['expression_label'])
|
|
group_line_dict['column_percent_comparison_data'] = self.report_id._compute_column_percent_comparison_data(
|
|
options, group_line_dict['columns'][0]['no_format'], group_line_dict['columns'][1]['no_format'], green_on_positive=compared_expression.green_on_positive)
|
|
# Manage budget comparison
|
|
elif options.get('column_percent_comparison') == 'budget':
|
|
self.report_id._set_budget_column_comparisons(options, group_line_dict)
|
|
|
|
group_lines_by_keys[grouping_key] = group_line_dict
|
|
|
|
# Sort grouping keys in the right order and generate line names
|
|
keys_and_names_in_sequence = {} # Order of this dict will matter
|
|
|
|
if groupby_model:
|
|
browsed_groupby_keys = self.env[groupby_model].browse(list(key for key in group_lines_by_keys if key is not None))
|
|
|
|
out_of_sorting_record = None
|
|
records_to_sort = browsed_groupby_keys
|
|
if browsed_groupby_keys and load_one_more and len(browsed_groupby_keys) >= limit:
|
|
out_of_sorting_record = browsed_groupby_keys[-1]
|
|
records_to_sort = records_to_sort[:-1]
|
|
|
|
for record in records_to_sort.with_context(active_test=False).sorted():
|
|
keys_and_names_in_sequence[record.id] = record.display_name
|
|
|
|
if None in group_lines_by_keys:
|
|
keys_and_names_in_sequence[None] = _("Unknown")
|
|
|
|
if out_of_sorting_record:
|
|
keys_and_names_in_sequence[out_of_sorting_record.id] = out_of_sorting_record.display_name
|
|
|
|
else:
|
|
for non_relational_key in sorted(group_lines_by_keys.keys(), key=lambda k: (k is None, k)):
|
|
if custom_groupby_name_builder := custom_groupby_map.get(current_groupby, {}).get('label_builder'):
|
|
keys_and_names_in_sequence[non_relational_key] = custom_groupby_name_builder(non_relational_key)
|
|
else:
|
|
keys_and_names_in_sequence[non_relational_key] = str(non_relational_key) if non_relational_key is not None else _("Unknown")
|
|
|
|
# Build result: add a name to the groupby lines and handle totals below section for multi-level groupby
|
|
group_lines = []
|
|
for grouping_key, line_name in keys_and_names_in_sequence.items():
|
|
group_line_dict = group_lines_by_keys[grouping_key]
|
|
group_line_dict['name'] = line_name
|
|
group_lines.append(group_line_dict)
|
|
|
|
if options.get('hierarchy'):
|
|
group_lines = self.report_id._create_hierarchy(group_lines, options)
|
|
|
|
return group_lines
|
|
|
|
def _get_groupby_line_name(self, groupby_field_name, groupby_model, grouping_key):
|
|
if groupby_model is None:
|
|
return grouping_key
|
|
|
|
if grouping_key is None:
|
|
return _("Unknown")
|
|
|
|
return self.env[groupby_model].browse(grouping_key).display_name
|
|
|
|
def _parse_groupby(self, options, groupby_to_expand=None):
|
|
""" Retrieves the information needed to handle the groupby feature on the current line.
|
|
|
|
:param groupby_to_expand: A coma-separated string containing, in order, all the fields that are used in the groupby we're expanding.
|
|
None if we're not expanding anything.
|
|
|
|
:return: A dictionary with 4 keys:
|
|
'current_groupby': The name of the value to be used to retrieve the results of the current groupby we're
|
|
expanding, or None if nothing is being expanded. That value can be either a field of account.move.line, or
|
|
a custom groupby value defined in this report's custom handler's _get_custom_groupby_map function.
|
|
|
|
'next_groupby': The subsequent groupings to be applied after current_groupby, as a string of coma-separated values (again,
|
|
either field names from account.move.line or a custom groupby defined on the handler).
|
|
If no subsequent grouping exists, next_groupby will be None.
|
|
|
|
'current_groupby_model': The model name corresponding to current_groupby, or None if current_groupby is None.
|
|
|
|
'custom_groupby_map'; The groupby map, used to handle custom groupby values, as returned by the _get_custom_groupby_map function
|
|
of the custom handler (by default, it will be an empty dict)
|
|
|
|
EXAMPLE:
|
|
When computing a line with groupby=partner_id,account_id,id , without expanding it:
|
|
- groupby_to_expand will be None
|
|
- current_groupby will be None
|
|
- next_groupby will be 'partner_id,account_id,id'
|
|
- current_groupby_model will be None
|
|
|
|
When expanding the first group level of the line:
|
|
- groupby_to_expand will be: partner_id,account_id,id
|
|
- current_groupby will be 'partner_id'
|
|
- next_groupby will be 'account_id,id'
|
|
- current_groupby_model will be 'res.partner'
|
|
|
|
When expanding further:
|
|
- groupby_to_expand will be: account_id,id ; corresponding to the next_groupby computed when expanding partner_id
|
|
- current_groupby will be 'account_id'
|
|
- next_groupby will be 'id'
|
|
- current_groupby_model will be 'account.account'
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if groupby_to_expand:
|
|
groupby_to_expand = groupby_to_expand.replace(' ', '')
|
|
split_groupby = groupby_to_expand.split(',')
|
|
current_groupby = split_groupby[0]
|
|
next_groupby = ','.join(split_groupby[1:]) if len(split_groupby) > 1 else None
|
|
else:
|
|
current_groupby = None
|
|
groupby = self._get_groupby(options)
|
|
next_groupby = groupby.replace(' ', '') if groupby else None
|
|
|
|
custom_handler_name = self.report_id._get_custom_handler_model()
|
|
custom_groupby_map = self.env[custom_handler_name]._get_custom_groupby_map() if custom_handler_name else {}
|
|
if current_groupby in custom_groupby_map:
|
|
groupby_model = custom_groupby_map[current_groupby]['model']
|
|
elif current_groupby == 'id':
|
|
groupby_model = 'account.move.line'
|
|
elif current_groupby:
|
|
groupby_model = self.env['account.move.line']._fields[current_groupby].comodel_name
|
|
else:
|
|
groupby_model = None
|
|
|
|
return {
|
|
'current_groupby': current_groupby,
|
|
'next_groupby': next_groupby,
|
|
'current_groupby_model': groupby_model,
|
|
'custom_groupby_map': custom_groupby_map,
|
|
}
|
|
|
|
def _get_groupby(self, options):
|
|
self.ensure_one()
|
|
if options['export_mode'] == 'file':
|
|
return self.groupby
|
|
return self.user_groupby
|
|
|
|
def action_reset_custom_groupby(self):
|
|
self.ensure_one()
|
|
self.user_groupby = self.groupby
|
|
|
|
|
|
class AccountReportExpression(models.Model):
|
|
_inherit = 'account.report.expression'
|
|
|
|
def action_view_carryover_lines(self, options, column_group_key=None):
|
|
if column_group_key:
|
|
options = self.report_line_id.report_id._get_column_group_options(options, column_group_key)
|
|
|
|
date_from, date_to = self.report_line_id.report_id._get_date_bounds_info(options, self.date_scope)
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Carryover lines for: %s', self.report_line_name),
|
|
'res_model': 'account.report.external.value',
|
|
'views': [(False, 'list')],
|
|
'domain': [
|
|
('target_report_expression_id', '=', self.id),
|
|
('date', '>=', date_from),
|
|
('date', '<=', date_to),
|
|
],
|
|
}
|
|
|
|
|
|
class AccountReportHorizontalGroup(models.Model):
|
|
_name = "account.report.horizontal.group"
|
|
_description = "Horizontal group for reports"
|
|
|
|
name = fields.Char(string="Name", required=True, translate=True)
|
|
rule_ids = fields.One2many(string="Rules", comodel_name='account.report.horizontal.group.rule', inverse_name='horizontal_group_id', required=True)
|
|
report_ids = fields.Many2many(string="Reports", comodel_name='account.report')
|
|
|
|
_sql_constraints = [
|
|
('name_uniq', 'unique (name)', "A horizontal group with the same name already exists."),
|
|
]
|
|
|
|
def _get_header_levels_data(self):
|
|
return [
|
|
(rule.field_name, rule._get_matching_records())
|
|
for rule in self.rule_ids
|
|
]
|
|
|
|
class AccountReportHorizontalGroupRule(models.Model):
|
|
_name = "account.report.horizontal.group.rule"
|
|
_description = "Horizontal group rule for reports"
|
|
|
|
def _field_name_selection_values(self):
|
|
return [
|
|
(aml_field['name'], aml_field['string'])
|
|
for aml_field in self.env['account.move.line'].fields_get().values()
|
|
if aml_field['type'] in ('many2one', 'many2many')
|
|
]
|
|
|
|
horizontal_group_id = fields.Many2one(string="Horizontal Group", comodel_name='account.report.horizontal.group', required=True)
|
|
domain = fields.Char(string="Domain", required=True, default='[]')
|
|
field_name = fields.Selection(string="Field", selection='_field_name_selection_values', required=True)
|
|
res_model_name = fields.Char(string="Model", compute='_compute_res_model_name')
|
|
|
|
@api.depends('field_name')
|
|
def _compute_res_model_name(self):
|
|
for record in self:
|
|
if record.field_name:
|
|
record.res_model_name = self.env['account.move.line']._fields[record.field_name].comodel_name
|
|
else:
|
|
record.res_model_name = None
|
|
|
|
def _get_matching_records(self):
|
|
self.ensure_one()
|
|
model_name = self.env['account.move.line']._fields[self.field_name].comodel_name
|
|
domain = ast.literal_eval(self.domain)
|
|
return self.env[model_name].search(domain)
|
|
|
|
|
|
class AccountReportCustomHandler(models.AbstractModel):
|
|
_name = 'account.report.custom.handler'
|
|
_description = 'Account Report Custom Handler'
|
|
|
|
# This abstract model allows case-by-case localized changes of behaviors of reports.
|
|
# This is used for custom reports, for cases that cannot be supported by the standard engines.
|
|
|
|
def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
|
|
""" Generates lines dynamically for reports that require a custom processing which cannot be handled
|
|
by regular report engines.
|
|
:return: A list of tuples [(sequence, line_dict), ...], where:
|
|
- sequence is the sequence to apply when rendering the line (can be mixed with static lines),
|
|
- line_dict is a dict containing all the line values.
|
|
"""
|
|
return []
|
|
|
|
def _caret_options_initializer(self):
|
|
""" Returns the caret options dict to be used when rendering this report,
|
|
in the same format as the one used in _caret_options_initializer_default (defined on 'account.report').
|
|
If the result is empty, the engine will use the default caret options.
|
|
"""
|
|
return self.env['account.report']._caret_options_initializer_default()
|
|
|
|
def _custom_options_initializer(self, report, options, previous_options):
|
|
""" To be overridden to add report-specific _init_options... code to the report. """
|
|
if report.root_report_id:
|
|
report.root_report_id._init_options_custom(options, previous_options)
|
|
|
|
def _custom_line_postprocessor(self, report, options, lines):
|
|
""" Postprocesses the result of the report's _get_lines() before returning it. """
|
|
return lines
|
|
|
|
def _custom_groupby_line_completer(self, report, options, line_dict):
|
|
""" Postprocesses the dict generated by the group_by_line, to customize its content. """
|
|
|
|
def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
|
|
""" When using the 'unfold all' option, some reports might end up recomputing the same query for
|
|
each line to unfold, leading to very inefficient computation. This function allows batching this computation,
|
|
and returns a dictionary where all results are cached, for use in expansion functions.
|
|
"""
|
|
return None
|
|
|
|
def _get_custom_display_config(self):
|
|
""" To be overridden in order to change the templates used by Javascript to render this report (keeping the same
|
|
OWL components), and/or replace some of the default OWL components by custom-made ones.
|
|
|
|
This function returns a dict (possibly empty, if there is no custom display config):
|
|
|
|
{
|
|
'css_custom_class: 'class',
|
|
'components': {
|
|
|
|
},
|
|
'pdf_export': {
|
|
|
|
},
|
|
'templates': {
|
|
|
|
},
|
|
},
|
|
"""
|
|
return {}
|
|
|
|
def _get_custom_groupby_map(self):
|
|
""" Allows the use of custom values in the groupby field of account.report.line, to use them in custom engines. Those custom
|
|
values can be anything, and need to be properly handled by the custom engine using them. This allows adding support for grouping on
|
|
something else than just the fields of account.move.line, which is the default.
|
|
|
|
:return: A dict, in the form {groupby_name: {'model': model, 'domain_builder': domain_builder}}, where:
|
|
- groupby_name is the custom value to use in groupby instead of one of aml's field names
|
|
- model: is a model name (a string), representing the model the value returned for this custom groupby targets.
|
|
The model will be used to compute the display_name to show for each generated groupby line, in the UI.
|
|
This value can be passed to None ; in such case, the raw value returned by the engine will be shown.
|
|
- domain_builder is a function to be called when expanding a groupby line generated by this custom groupby, to compute the
|
|
domain to apply in order to restrict the computation to the content of this groupby line.
|
|
This function must accept a single parameter, corresponding to the groupby value to compute the domain for.
|
|
- label_builder is a function to be called to compute a label for the groupby value, that will be shown as the line name
|
|
in the UI. This ways, translatable labels and multi-values keys serialized to json can be fully supported.
|
|
"""
|
|
return {}
|
|
|
|
def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
|
|
""" To be overridden to add report-specific warnings in the warnings dictionary.
|
|
When a root report defines something in this function, its variants without any custom handler will also call the root report's
|
|
_customize_warnings function. This can hence be used to share warnings between all variants.
|
|
|
|
Should only be used when necessary, _dynamic_lines_generator is preferred.
|
|
"""
|
|
|
|
def _enable_export_buttons_for_common_vat_groups_in_branches(self, options):
|
|
""" Helper function to be called in _custom_options_initializer to change the behavior of the report so that the export
|
|
buttons are all forced to 'branch_allowed' in case the currently selected company branches all share the same VAT number, and
|
|
no unselected sub-branch of the active company has the same VAT number. Companies without explicit VAT number (empty vat field)
|
|
will be considered as having the same VAT number as their closest parent with a non-empty VAT.
|
|
"""
|
|
report_accepted_company_ids = set(self.env['account.report'].get_report_company_ids(options))
|
|
same_vat_branch_ids = set(self.env.company._get_branches_with_same_vat().ids)
|
|
if report_accepted_company_ids == same_vat_branch_ids:
|
|
for button in options['buttons']:
|
|
button['branch_allowed'] = True
|
|
|
|
|
|
class AccountReportFileDownloadException(Exception):
|
|
def __init__(self, errors, content=None):
|
|
super().__init__()
|
|
self.errors = errors
|
|
self.content = content
|