diff --git a/mis_builder/models/__init__.py b/mis_builder/models/__init__.py index af7bfa7e5..610fea94d 100644 --- a/mis_builder/models/__init__.py +++ b/mis_builder/models/__init__.py @@ -8,3 +8,5 @@ from . import aep from . import mis_kpi_data from . import prorata_read_group_mixin +from . import account_account +from . import account_analytic_account diff --git a/mis_builder/models/account_account.py b/mis_builder/models/account_account.py new file mode 100644 index 000000000..aeaca3d2c --- /dev/null +++ b/mis_builder/models/account_account.py @@ -0,0 +1,10 @@ +# Copyright 2017 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.osv import expression + +class AccountAccount(models.Model): + _name = 'account.account' + _inherit = ['account.account'] \ No newline at end of file diff --git a/mis_builder/models/account_analytic_account.py b/mis_builder/models/account_analytic_account.py new file mode 100644 index 000000000..ad9825586 --- /dev/null +++ b/mis_builder/models/account_analytic_account.py @@ -0,0 +1,10 @@ +# Copyright 2017 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.osv import expression + +class AccountAnalyticAccount(models.Model): + _name = 'account.analytic.account' + _inherit = ['account.analytic.account'] diff --git a/mis_builder/models/aep.py b/mis_builder/models/aep.py index 5cc79ff2c..40de3cfd4 100644 --- a/mis_builder/models/aep.py +++ b/mis_builder/models/aep.py @@ -11,7 +11,7 @@ from odoo.tools.safe_eval import datetime, dateutil, safe_eval, time from .accounting_none import AccountingNone - +import pprint try: import itertools.izip as zip except ImportError: @@ -306,6 +306,8 @@ def do_queries( date_to, additional_move_line_filter=None, aml_model=None, + auto_expand_col_name = None, + rdi_transformer = lambda i: (i[0], str(i[1])) if i else ('other', _('Other')) ): """Query sums of debit and credit for all accounts and domains used in expressions. @@ -323,7 +325,7 @@ def do_queries( domain_by_mode = {} ends = [] for key in self._map_account_ids: - domain, mode = key + (domain, mode) = key if mode == self.MODE_END and self.smart_end: # postpone computation of ending balance ends.append((domain, mode)) @@ -336,13 +338,21 @@ def do_queries( domain.append(("account_id", "in", self._map_account_ids[key])) if additional_move_line_filter: domain.extend(additional_move_line_filter) + + get_fields = ["debit", "credit", "account_id", "company_id"] + group_by_fields = ["account_id", "company_id"] + if auto_expand_col_name: + get_fields = [ auto_expand_col_name ] + get_fields + group_by_fields = [ auto_expand_col_name ] + group_by_fields + # fetch sum of debit/credit, grouped by account_id accs = aml_model.read_group( domain, - ["debit", "credit", "account_id", "company_id"], - ["account_id", "company_id"], + get_fields, + group_by_fields, lazy=False, ) + for acc in accs: rate, dp = company_rates[acc["company_id"][0]] debit = acc["debit"] or 0.0 @@ -352,19 +362,33 @@ def do_queries( ): # in initial mode, ignore accounts with 0 balance continue - self._data[key][acc["account_id"][0]] = (debit * rate, credit * rate) + rdi_id = rdi_transformer(acc[auto_expand_col_name]) + if not self._data[key].get(rdi_id, False): + self._data[key][rdi_id] = defaultdict(dict) + self._data[key][rdi_id][acc["account_id"][0]] = (debit * rate, credit * rate) # compute ending balances by summing initial and variation for key in ends: domain, mode = key initial_data = self._data[(domain, self.MODE_INITIAL)] variation_data = self._data[(domain, self.MODE_VARIATION)] - account_ids = set(initial_data.keys()) | set(variation_data.keys()) - for account_id in account_ids: - di, ci = initial_data.get(account_id, (AccountingNone, AccountingNone)) - dv, cv = variation_data.get( - account_id, (AccountingNone, AccountingNone) - ) - self._data[key][account_id] = (di + dv, ci + cv) + rdis = set(initial_data.keys()) | set(variation_data.keys()) + for rdi in rdis: + if not initial_data.get(rdi, False): + initial_data[rdi] = defaultdict(dict) + if not variation_data.get(rdi, False): + variation_data[rdi] = defaultdict(dict) + if not self._data[key].get(rdi, False): + self._data[key][rdi] = defaultdict(dict) + pprint.pprint(rdis) + pprint.pprint(rdi) + pprint.pprint(initial_data) + pprint.pprint(variation_data) + + account_ids = set(initial_data[rdi].keys()) | set(variation_data[rdi].keys()) + for account_id in account_ids: + di, ci = initial_data[rdi].get(account_id, (AccountingNone, AccountingNone)) + dv, cv = variation_data[rdi].get(account_id, (AccountingNone, AccountingNone)) + self._data[key][rdi][account_id] = (di + dv, ci + cv) def replace_expr(self, expr): """Replace accounting variables in an expression by their amount. @@ -377,23 +401,25 @@ def replace_expr(self, expr): def f(mo): field, mode, acc_domain, ml_domain = self._parse_match_object(mo) key = (ml_domain, mode) - account_ids_data = self._data[key] + rdi_ids_data = self._data[key] v = AccountingNone account_ids = self._account_ids_by_acc_domain[acc_domain] - for account_id in account_ids: - debit, credit = account_ids_data.get( - account_id, (AccountingNone, AccountingNone) - ) - if field == "bal": - v += debit - credit - elif field == "pbal" and debit >= credit: - v += debit - credit - elif field == "nbal" and debit < credit: - v += debit - credit - elif field == "deb": - v += debit - elif field == "crd": - v += credit + for rdi in rdi_ids_data: + account_ids_data = self._data[key][rdi] + for account_id in account_ids: + debit, credit = account_ids_data.get( + account_id, (AccountingNone, AccountingNone) + ) + if field == "bal": + v += debit - credit + elif field == "pbal" and debit >= credit: + v += debit - credit + elif field == "nbal" and debit < credit: + v += debit - credit + elif field == "deb": + v += debit + elif field == "crd": + v += credit # in initial balance mode, assume 0 is None # as it does not make sense to distinguish 0 from "no data" if ( @@ -424,25 +450,21 @@ def f(mo): return "(AccountingNone)" # here we know account_id is involved in acc_domain account_ids_data = self._data[key] - debit, credit = account_ids_data.get( - account_id, (AccountingNone, AccountingNone) - ) - if field == "bal": - v = debit - credit - elif field == "pbal": - if debit >= credit: - v = debit - credit - else: - v = AccountingNone - elif field == "nbal": - if debit < credit: - v = debit - credit - else: - v = AccountingNone - elif field == "deb": - v = debit - elif field == "crd": - v = credit + for rdi in rdi_ids_data: + account_ids_data = self._data[key][rdi] + debit, credit = account_ids_data.get( + account_id, (AccountingNone, AccountingNone) + ) + if field == "bal": + v += debit - credit + elif field == "pbal" and debit >= credit: + v += debit - credit + elif field == "nbal" and debit < credit: + v += debit - credit + elif field == "deb": + v += debit + elif field == "crd": + v += credit # in initial balance mode, assume 0 is None # as it does not make sense to distinguish 0 from "no data" if ( @@ -466,6 +488,58 @@ def f(mo): for account_id in account_ids: yield account_id, [self._ACC_RE.sub(f, expr) for expr in exprs] + def replace_exprs_by_row_detail(self, exprs): + """Replace accounting variables in a list of expression + by their amount, iterating by accounts involved in the expression. + + yields account_id, replaced_expr + + This method must be executed after do_queries(). + """ + + def f(mo): + field, mode, acc_domain, ml_domain = self._parse_match_object(mo) + key = (ml_domain, mode) + v = AccountingNone + account_ids_data = self._data[key][rdi_id] + account_ids = self._account_ids_by_acc_domain[acc_domain] + + for account_id in account_ids: + debit, credit = account_ids_data.get( + account_id, (AccountingNone, AccountingNone) + ) + if field == "bal": + v += debit - credit + elif field == "pbal" and debit >= credit: + v += debit - credit + elif field == "nbal" and debit < credit: + v += debit - credit + elif field == "deb": + v += debit + elif field == "crd": + v += credit + # in initial balance mode, assume 0 is None + # as it does not make sense to distinguish 0 from "no data" + if ( + v is not AccountingNone + and mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED) + and float_is_zero(v, precision_digits=self.dp) + ): + v = AccountingNone + return "(" + repr(v) + ")" + + rdi_ids = set() + for expr in exprs: + for mo in self._ACC_RE.finditer(expr): + field, mode, acc_domain, ml_domain = self._parse_match_object(mo) + key = (ml_domain, mode) + rdis_data = self._data[key] + for rdi_id in rdis_data.keys(): + rdi_ids.add(rdi_id) + + for rdi_id in rdi_ids: + yield rdi_id, [self._ACC_RE.sub(f, expr) for expr in exprs] + @classmethod def _get_balances(cls, mode, companies, date_from, date_to): expr = "deb{mode}[], crd{mode}[]".format(mode=mode) diff --git a/mis_builder/models/expression_evaluator.py b/mis_builder/models/expression_evaluator.py index 94ff403cb..20b1c0109 100644 --- a/mis_builder/models/expression_evaluator.py +++ b/mis_builder/models/expression_evaluator.py @@ -25,13 +25,14 @@ def __init__( self.aml_model = aml_model self._aep_queries_done = False - def aep_do_queries(self): + def aep_do_queries(self, auto_expand_col_name = None): if self.aep and not self._aep_queries_done: self.aep.do_queries( self.date_from, self.date_to, self.additional_move_line_filter, self.aml_model, + auto_expand_col_name ) self._aep_queries_done = True @@ -55,6 +56,7 @@ def eval_expressions(self, expressions, locals_dict): drilldown_args.append(None) return vals, drilldown_args, name_error + # TODO maybe depreciated def eval_expressions_by_account(self, expressions, locals_dict): if not self.aep: return @@ -71,3 +73,20 @@ def eval_expressions_by_account(self, expressions, locals_dict): else: drilldown_args.append(None) yield account_id, vals, drilldown_args, name_error + + def eval_expressions_by_row_detail(self, expressions, locals_dict): + if not self.aep: + return + exprs = [e and e.name or "AccountingNone" for e in expressions] + for rdi_id, replaced_exprs in self.aep.replace_exprs_by_row_detail(exprs): + vals = [] + drilldown_args = [] + name_error = False + for expr, replaced_expr in zip(exprs, replaced_exprs): + val = mis_safe_eval(replaced_expr, locals_dict) + vals.append(val) + if replaced_expr != expr: + drilldown_args.append({"expr": expr, "row_detail": rdi_id}) + else: + drilldown_args.append(None) + yield rdi_id, vals, drilldown_args, name_error \ No newline at end of file diff --git a/mis_builder/models/kpimatrix.py b/mis_builder/models/kpimatrix.py index 8b89b28fe..e63b30667 100644 --- a/mis_builder/models/kpimatrix.py +++ b/mis_builder/models/kpimatrix.py @@ -4,7 +4,7 @@ import logging from collections import OrderedDict, defaultdict -from odoo import _ +from odoo import _, models from odoo.exceptions import UserError from .accounting_none import AccountingNone @@ -21,6 +21,26 @@ _logger = logging.getLogger(__name__) +class RowDetailIdentifier(): + def __init__(self, id, label): + self.id = id + self.label = label + + def get_id(self): + """ return a python compatible row id """ + from .mis_report import _python_var + return _python_var(self.get_label()) + + def get_label(self): + """ return the row label """ + return self.label + +# def __hash__(self): +# pass + + def __lt__(self, other): # TODO assess utility + return self.get_label() < other.get_label() + class KpiMatrixRow(object): # TODO: ultimately, the kpi matrix will become ignorant of KPI's and @@ -28,13 +48,13 @@ class KpiMatrixRow(object): # It is already ignorant of period and only knowns about columns. # This will require a correct abstraction for expanding row details. - def __init__(self, matrix, kpi, account_id=None, parent_row=None): + def __init__(self, matrix, kpi, row_detail_identifier=None, parent_row=None): self._matrix = matrix self.kpi = kpi - self.account_id = account_id + self.row_detail_identifier = row_detail_identifier self.description = "" self.parent_row = parent_row - if not self.account_id: + if not self.row_detail_identifier: self.style_props = self._matrix._style_model.merge( [self.kpi.report_id.style_id, self.kpi.style_id] ) @@ -45,17 +65,17 @@ def __init__(self, matrix, kpi, account_id=None, parent_row=None): @property def label(self): - if not self.account_id: + if not self.row_detail_identifier: return self.kpi.description else: - return self._matrix.get_account_name(self.account_id) + return self.row_detail_identifier.get_label() @property def row_id(self): - if not self.account_id: + if not self.row_detail_identifier: return self.kpi.name else: - return "{}:{}".format(self.kpi.name, self.account_id) + return "{}:{}".format(self.kpi.name, self.row_detail_identifier.get_id()) def iter_cell_tuples(self, cols=None): if cols is None: @@ -217,26 +237,26 @@ def set_values(self, kpi, col_key, vals, drilldown_args, tooltips=True): Invoke this after declaring the kpi and the column. """ - self.set_values_detail_account( + self.set_values_detail( kpi, col_key, None, vals, drilldown_args, tooltips ) - def set_values_detail_account( - self, kpi, col_key, account_id, vals, drilldown_args, tooltips=True + def set_values_detail( + self, kpi, col_key, row_detail_identifier, vals, drilldown_args, tooltips=True ): """Set values for a kpi and a column and a detail account. Invoke this after declaring the kpi and the column. """ - if not account_id: + if not row_detail_identifier: row = self._kpi_rows[kpi] else: kpi_row = self._kpi_rows[kpi] - if account_id in self._detail_rows[kpi]: - row = self._detail_rows[kpi][account_id] + if row_detail_identifier.get_id() in self._detail_rows[kpi]: + row = self._detail_rows[kpi][row_detail_identifier.get_id()] else: - row = KpiMatrixRow(self, kpi, account_id, parent_row=kpi_row) - self._detail_rows[kpi][account_id] = row + row = KpiMatrixRow(self, kpi, row_detail_identifier, parent_row=kpi_row) + self._detail_rows[kpi][row_detail_identifier.get_id()] = row col = self._cols[col_key] cell_tuple = [] assert len(vals) == col.colspan @@ -412,7 +432,7 @@ def compute_sums(self): for row in self.iter_rows(): acc = SimpleArray([AccountingNone] * (len(common_subkpis) or 1)) if row.kpi.accumulation_method == ACC_SUM and not ( - row.account_id and not sum_accdet + row.row_detail_identifier and not sum_accdet ): for sign, col_to_sum in col_to_sum_keys: cell_tuple = self._cols[col_to_sum].get_cell_tuple_for_row(row) @@ -429,10 +449,10 @@ def compute_sums(self): acc += SimpleArray(vals) else: acc -= SimpleArray(vals) - self.set_values_detail_account( + self.set_values_detail( row.kpi, sumcol_key, - row.account_id, + row.row_detail_identifier, acc, [None] * (len(common_subkpis) or 1), tooltips=False, diff --git a/mis_builder/models/mis_report.py b/mis_builder/models/mis_report.py index 9e49c9a3e..63905ddf9 100644 --- a/mis_builder/models/mis_report.py +++ b/mis_builder/models/mis_report.py @@ -25,7 +25,7 @@ from .aep import AccountingExpressionProcessor as AEP from .aggregate import _avg, _max, _min, _sum from .expression_evaluator import ExpressionEvaluator -from .kpimatrix import KpiMatrix +from .kpimatrix import KpiMatrix, RowDetailIdentifier from .mis_kpi_data import ACC_AVG, ACC_NONE, ACC_SUM from .mis_report_style import CMP_DIFF, CMP_NONE, CMP_PCT, TYPE_NUM, TYPE_PCT, TYPE_STR from .mis_safe_eval import DataError @@ -92,12 +92,14 @@ class MisReportKpi(models.Model): copy=True, string="Expressions", ) - auto_expand_accounts = fields.Boolean(string="Display details by account") + + auto_expand_accounts = fields.Boolean(string="Display details") auto_expand_accounts_style_id = fields.Many2one( - string="Style for account detail rows", + string="Style for details rows", comodel_name="mis.report.style", required=False, ) + style_id = fields.Many2one( string="Style", comodel_name="mis.report.style", required=False ) @@ -438,6 +440,7 @@ def _default_move_lines_source(self): return self.env["ir.model"].search([("model", "=", "account.move.line")]) name = fields.Char(required=True, string="Name", translate=True) + description = fields.Char(required=False, string="Description", translate=True) style_id = fields.Many2one(string="Style", comodel_name="mis.report.style") query_ids = fields.One2many( @@ -476,6 +479,20 @@ def _default_move_lines_source(self): compute="_compute_account_model", string="Account model" ) + auto_expand_col_name = fields.Selection( + [ + ("account_id", _("Accounts")), + ("analytic_account_id", _("Analytic Accounts")), + ("partner_id", _("Parner")) + ], + required=True, + string="Auto Expand Details", + default="account_id", + help="Allow to drilldown kpis by the specified field, " + "it need to be activated in each kpi. You can hide null " + "lines in the style configuration." + ) + @api.depends("kpi_ids", "subreport_ids") def _compute_all_kpi_ids(self): for rec in self: @@ -563,8 +580,7 @@ def _prepare_aep(self, companies, currency=None): aep = AEP(companies, currency, self.account_model) for kpi in self.all_kpi_ids: for expression in kpi.expression_ids: - if expression.name: - aep.parse_expr(expression.name) + aep.parse_expr(expression.name) aep.done_parsing() return aep @@ -663,7 +679,8 @@ def _declare_and_compute_col( # noqa: C901 (TODO simplify this fnction) col_description, subkpis_filter, locals_dict, - no_auto_expand_accounts=False, + no_auto_expand_accounts + =False, ): """This is the main computation loop. @@ -759,21 +776,24 @@ def _declare_and_compute_col( # noqa: C901 (TODO simplify this fnction) ): continue + rdis = expression_evaluator.eval_expressions_by_row_detail( + expressions, locals_dict #, self.auto_expand_col_name + ) for ( - account_id, + rdi, vals, drilldown_args, _name_error, - ) in expression_evaluator.eval_expressions_by_account( - expressions, locals_dict - ): + ) in rdis : for drilldown_arg in drilldown_args: if not drilldown_arg: continue drilldown_arg["period_id"] = col_key drilldown_arg["kpi_id"] = kpi.id - kpi_matrix.set_values_detail_account( - kpi, col_key, account_id, vals, drilldown_args + if not self._should_display_auto_expand(kpi, rdi, vals): + continue + kpi_matrix.set_values_detail( + kpi, col_key, RowDetailIdentifier(rdi[0], rdi[1]), vals, drilldown_args, ) if len(recompute_queue) == 0: @@ -891,7 +911,7 @@ def _declare_and_compute_period( ) # use AEP to do the accounting queries - expression_evaluator.aep_do_queries() + rdis = expression_evaluator.aep_do_queries(self.auto_expand_col_name) self._declare_and_compute_col( expression_evaluator, @@ -1012,3 +1032,9 @@ def _evaluate( no_auto_expand_accounts=True, ) return locals_dict + + def _should_display_auto_expand(self, kpi, rdi, vals): + if self.auto_expand_col_name == "account_id" + and self.rdi.id not in kpi + + return True diff --git a/mis_builder/views/mis_report.xml b/mis_builder/views/mis_report.xml index 466f006ed..602e1c199 100644 --- a/mis_builder/views/mis_report.xml +++ b/mis_builder/views/mis_report.xml @@ -20,6 +20,7 @@ +