Files
enterprise-addons/account_reports/models/account_analytic_report.py
T

268 lines
14 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
from odoo import _, models, fields, api
from odoo.fields import Domain
from odoo.addons.web.controllers.utils import clean_action
from odoo.tools import SQL, Query
class AccountReport(models.AbstractModel):
_inherit = 'account.report'
filter_analytic_groupby = fields.Boolean(
string="Analytic Group By",
compute=lambda x: x._compute_report_option_filter('filter_analytic_groupby'), readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
def _get_options_initializers_forced_sequence_map(self):
""" Force the sequence for the init_options so columns headers are already generated but not the columns
So, between _init_options_column_headers and _init_options_columns"""
sequence_map = super(AccountReport, self)._get_options_initializers_forced_sequence_map()
sequence_map[self._init_options_analytic_groupby] = 995
return sequence_map
def _init_options_analytic_groupby(self, options, previous_options):
if not self.filter_analytic_groupby:
return
enable_analytic_accounts = self.env.user.has_group('analytic.group_analytic_accounting')
if not enable_analytic_accounts:
return
options['display_analytic_groupby'] = True
options['display_analytic_plan_groupby'] = True
options['include_analytic_without_aml'] = previous_options.get('include_analytic_without_aml', False)
previous_analytic_accounts = previous_options.get('analytic_accounts_groupby', [])
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['analytic_accounts_groupby'] = selected_analytic_accounts.ids
options['selected_analytic_account_groupby_names'] = selected_analytic_accounts.mapped('name')
previous_analytic_plans = previous_options.get('analytic_plans_groupby', [])
analytic_plan_ids = [int(x) for x in previous_analytic_plans]
selected_analytic_plans = self.env['account.analytic.plan'].search([('id', 'in', analytic_plan_ids)])
options['analytic_plans_groupby'] = selected_analytic_plans.ids
options['selected_analytic_plan_groupby_names'] = selected_analytic_plans.mapped('name')
self._create_column_analytic(options)
def _create_column_analytic(self, options):
""" Creates the analytic columns for each plan or account in the filters.
This will duplicate all previous columns and adding the analytic accounts in the domain of the added columns.
The analytic_groupby_option is used so the table used is the shadowed table.
The domain on analytic_distribution can just use simple comparison as the column of the shadowed
table will simply be filled with analytic_account_ids.
"""
analytic_headers = []
plans = self.env['account.analytic.plan'].browse(options.get('analytic_plans_groupby'))
for plan in plans:
account_list = []
accounts = self.env['account.analytic.account'].search([('plan_id', 'child_of', plan.id)])
for account in accounts:
account_list.append(account.id)
analytic_headers.append({
'name': plan.name,
'forced_options': {
'analytic_groupby_option': True,
'analytic_accounts_list': tuple(account_list), # Analytic accounts used in the domain to filter the lines.
'analytic_plan_id': plan.id,
}
})
accounts = self.env['account.analytic.account'].browse(options.get('analytic_accounts_groupby'))
for account in accounts:
analytic_headers.append({
'name': account.name,
'forced_options': {
'analytic_groupby_option': True,
'analytic_accounts_list': (account.id,),
}
})
if analytic_headers:
has_selected_budgets = any([budget for budget in options.get('budgets', []) if budget['selected']])
if has_selected_budgets and not options['selected_horizontal_group_id']:
# if budget is selected, then analytic headers are placed on the same header level
options['column_headers'][-1] = analytic_headers + options['column_headers'][-1]
else:
# We add the analytic layer to the column_headers before creating the columns
analytic_headers.append({'name': _("Total")})
options['column_headers'] = [
*options['column_headers'],
analytic_headers,
]
@api.model
def _create_aml_shadowing_query_for_analytic_groupby(self):
"""Prepares a SQL query to shadow the account_move_line table with
the data from account_analytic_line in reports.
It will return a new table with the schema of account_move_line table, but with
the data from account_analytic_line.
We inherit the schema of account_move_line, make the correspondence between
account_move_line fields and account_analytic_line fields and put NULL for those
who don't exist in account_analytic_line.
We also drop the NOT NULL constraints for fields who are not required in account_analytic_line.
"""
project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
analytic_cols = SQL(", ").join(SQL('"account_analytic_line".%s', SQL.identifier(n._column_name())) for n in (project_plan + other_plans))
analytic_distribution_equivalent = SQL('to_jsonb(UNNEST(ARRAY_REMOVE(ARRAY[%s], NULL)))', analytic_cols)
change_equivalence_dict = {
'balance': SQL("-amount"),
'display_type': 'product',
'parent_state': 'posted',
'account_id': SQL.identifier("general_account_id"),
'debit': SQL("CASE WHEN (amount < 0) THEN -amount else 0 END"),
'credit': SQL("CASE WHEN (amount > 0) THEN amount else 0 END"),
'analytic_distribution': analytic_distribution_equivalent,
'date': SQL("account_analytic_line.date"),
'company_id': SQL("account_analytic_line.company_id"),
}
all_stored_aml_fields = {
field
for field, attrs in self.env['account.move.line'].fields_get(attributes=["type", "store"]).items()
if attrs['type'] not in ['many2many', 'one2many'] and attrs.get('store')
}
for aml_field in all_stored_aml_fields:
if aml_field not in change_equivalence_dict:
change_equivalence_dict[aml_field] = SQL('"account_move_line".%s', SQL.identifier(aml_field))
_stored_fields, fields_to_insert = self.env['account.move.line']._prepare_aml_shadowing_for_report(change_equivalence_dict, prefix_fields_to_insert=False)
return SQL("""
(
SELECT %(fields_to_insert)s
FROM account_analytic_line
LEFT JOIN account_move_line
ON account_analytic_line.move_line_id = account_move_line.id
WHERE
account_analytic_line.general_account_id IS NOT NULL
)
""", fields_to_insert=fields_to_insert)
def _get_report_query(self, options, date_scope, domain=None) -> Query:
# Override to add the context key which will eventually trigger the shadowing of the table
context_self = self.with_context(account_report_analytic_groupby=options.get('analytic_groupby_option'))
# We add the domain filter for analytic_distribution here, as the search is not available
query = super(AccountReport, context_self)._get_report_query(options, date_scope, domain)
if options.get('analytic_accounts'):
if 'analytic_accounts_list' in options:
# the table `account_move_line` will be shadowed by _prepare_lines_for_analytic_groupby and thus analytic_distribution will be a single ID
analytic_account_ids = tuple(str(account_id) for account_id in options['analytic_accounts'])
query.add_where(SQL("""account_move_line.analytic_distribution IN %s""", analytic_account_ids))
else:
# Real `account_move_line` table so real JSON with percentage
analytic_account_ids = [[str(account_id) for account_id in options['analytic_accounts']]]
query.add_where(SQL('%s && %s', analytic_account_ids, self.env['account.move.line']._query_analytic_accounts()))
return query
def action_audit_cell(self, options, params):
column_group_options = self._get_column_group_options(options, params['column_group_key'])
if not column_group_options.get('analytic_groupby_option'):
action = super().action_audit_cell(options, params)
if options.get('column_percent_comparison') == 'analytic_coverage':
context = action.get('context', {})
context.update({
'selected_analytic_plan': options['analytic_plans_groupby'][0],
})
action['context'] = context
view_id = self.env.ref('account_reports.view_analytic_move_line_tree', raise_if_not_found=False) or False
if view_id:
action['views'] = [(view_id.id, 'list')]
return action
else:
# Start by getting the domain from the options. Note that this domain is targeting account.move.line
report_line = self.env['account.report.line'].browse(params['report_line_id'])
expression = report_line.expression_ids.filtered(lambda x: x.label == params['expression_label'])
line_domain = self._get_audit_line_domain(column_group_options, expression, params)
# The line domain is made for move lines, so we need some postprocessing to have it work with analytic lines.
domain = []
AccountAnalyticLine = self.env['account.analytic.line']
for expression in line_domain:
if len(expression) == 1: # For operators such as '&' or '|' we can juste add them again.
domain.append(expression)
continue
field, operator, right_term = expression
# On analytic lines, the account.account field is named general_account_id and not account_id.
if field.split('.')[0] == 'account_id':
field = field.replace('account_id', 'general_account_id')
expression = [(field, operator, right_term)]
elif field == 'analytic_distribution':
if options.get('column_percent_comparison') == 'analytic_coverage':
expression = [(1, '=', 1)]
else:
expression = [('auto_account_id', 'in', right_term)]
# For other fields not present in on the analytic line model, map them to get the info from the move_line.
# Or ignore these conditions if there is no move lines.
elif field.split('.')[0] not in AccountAnalyticLine._fields:
expression = [(f'move_line_id.{field}', operator, right_term)]
if options.get('include_analytic_without_aml'):
expression = Domain.OR([
[('move_line_id', '=', False)],
expression,
])
else:
expression = [expression] # just for the extend
domain.extend(expression)
action = clean_action(self.env.ref('analytic.account_analytic_line_action_entries')._get_action_dict(), env=self.env)
action['domain'] = domain
if options.get('column_percent_comparison') == 'analytic_coverage':
context = ast.literal_eval(action.get('context', "{}"))
context.update({
'selected_analytic_plan': options['analytic_plans_groupby'][0],
'group_by': 'move_line_id',
})
action['context'] = context
action['display_name'] += ' - ' + options['selected_analytic_plan_groupby_names'][0]
view_id = self.env.ref('account_reports.view_analytic_line_tree', raise_if_not_found=False) or False
if view_id:
action['views'] = [(view_id.id, 'list')]
return action
@api.model
def _get_options_journals_domain(self, options):
domain = super()._get_options_journals_domain(options)
# Add False to the domain in order to select lines without journals for analytics columns.
if options.get('include_analytic_without_aml'):
domain |= Domain('journal_id', '=', False)
return domain
def _get_options_domain(self, options, date_scope):
self.ensure_one()
domain = super()._get_options_domain(options, date_scope)
# Get the analytic accounts that we need to filter on from the options and add a domain for them.
if 'analytic_accounts_list' in options:
domain &= Domain('analytic_distribution', 'in', options.get('analytic_accounts_list', []))
return domain
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
@api.model
def _search(self, *args, **kwargs):
""" In case we need an analytic column in an account_report, we shadow the account_move_line table
with a temp table filled with analytic data, that will be used for the analytic columns.
We do it in this function to only create and fill it once for all computations of a report.
The following analytic columns and computations will just query the shadowed table instead of the real one.
"""
query = super()._search(*args, **kwargs)
if self.env.context.get('account_report_analytic_groupby') and not self.env.context.get('account_report_cash_basis'):
query._tables['account_move_line'] = self.env['account.report']._create_aml_shadowing_query_for_analytic_groupby()
return query