diff --git a/mis_builder/models/aep.py b/mis_builder/models/aep.py index bb722b583..a5af8efb5 100644 --- a/mis_builder/models/aep.py +++ b/mis_builder/models/aep.py @@ -313,6 +313,25 @@ def get_account_ids_for_expr(self, expr): account_ids.update(self._account_ids_by_acc_domain[acc_domain]) return account_ids + def get_accounting_variables_for_expr(self, expr): + """Return the details of an expression. Used for consistency checks. + + Prerequisite: done_parsing() must have been invoked. + + Returns a list of (field, mode, account_ids, expression_item) + used in the expression. expression_item is useful to have + accurate error messages. + """ + res = [] + for mo in self._ACC_RE.finditer(expr): + field, mode, _, acc_domain, _ = self._parse_match_object(mo) + account_ids = self._account_ids_by_acc_domain[acc_domain] + expr_item_str = mo.group() + res.append( + (field, mode, account_ids, expr_item_str and expr_item_str.strip()) + ) + return res + def get_aml_domain_for_expr(self, expr, date_from, date_to, account_id=None): """Get a domain on account.move.line for an expression. diff --git a/mis_builder/models/mis_report.py b/mis_builder/models/mis_report.py index c4442f239..4e734ba5c 100644 --- a/mis_builder/models/mis_report.py +++ b/mis_builder/models/mis_report.py @@ -461,6 +461,12 @@ def _default_move_lines_source(self): "data source for column Actuals.", ) account_model = fields.Char(compute="_compute_account_model") + constraint_type = fields.Selection( + [ + ("profit_and_loss", "Profit & Loss"), + ("balance_sheet", "Balance Sheet"), + ] + ) @api.depends("kpi_ids", "subreport_ids") def _compute_all_kpi_ids(self): @@ -1008,3 +1014,280 @@ def _evaluate( no_auto_expand_accounts=True, ) return locals_dict + + def _check_constraint(self, companies): + self.ensure_one() + if self.constraint_type == "profit_and_loss": + self._profit_and_loss_check_constraint(companies) + elif self.constraint_type == "balance_sheet": + self._balance_sheet_check_constraint(companies) + + def test_constraint_type_button(self): + self.ensure_one() + company = self.env.company + self._check_constraint(company) + action = { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "message": self.env._( + "Test successful in company '%s'.", company.display_name + ), + "type": "success", + "sticky": False, + }, + } + return action + + @api.model + def _profit_and_loss_check_constraint(self, companies, raise_if_errors=True): + """Validate expressions for a P&L report. + + - 'balp' only + - each income and expense account of the company is used exactly once + TODO check that expense accounts have +balp and income accounts have -balp + """ + aep = self._prepare_aep(companies) + acc_obj = self.env["account.account"].with_company(companies[0]) + errors = [] + account_id2locations = defaultdict(list) + income_expense_account_ids = set( + self.env["account.account"]._search( + [ + ("company_ids", "in", companies.ids), + ( + "account_type", + "in", + ( + "income", + "income_other", + "expense", + "expense_depreciation", + "expense_direct_cost", + ), + ), + ] + ) + ) + for kpi in self.kpi_ids: + for expression in kpi.expression_ids: + expr_props = aep.get_accounting_variables_for_expr(expression.name) + for field, mode, account_ids, expr_item_str in expr_props: + if not account_ids: + continue + # balp only + if field != "bal" or mode != aep.MODE_VARIATION: + errors.append( + self.env._( + "KPI '%(kpi)s' has expression '%(expression_item)s' " + "but only 'balp' can be used on P&L reports.", + kpi=kpi.display_name, + expression_item=expr_item_str, + ) + ) + continue + # Check we don't have non income-expense accounts + bad_type_account_ids = account_ids - income_expense_account_ids + if bad_type_account_ids: + bad_type_accounts = acc_obj.browse(bad_type_account_ids) + errors.append( + self.env._( + "KPI '%(kpi)s' has expression '%(expression_item)s' " + "which uses non expense/income account(s): " + "%(accounts)s.", + kpi=kpi.display_name, + expression_item=expr_item_str, + accounts=", ".join( + [acc.display_name for acc in bad_type_accounts] + ), + ) + ) + continue + for account_id in account_ids: + account_id2locations[account_id].append( + (kpi.display_name, expr_item_str) + ) + missing_account_ids = [] + # Income-expense accounts used more than once + for account_id, location_list in account_id2locations.items(): + if not location_list: + missing_account_ids.append(account_id) + elif len(location_list) > 1: + account = acc_obj.browse(account_id) + errors.append( + self.env._( + "Account '%(account)s' is used several times: %(locations)s.", + account=account.display_name, + locations=", ".join( + [ + self.env._( + "KPI '%(kpi)s' formula '%(expr_item_str)s'", + kpi=kpi, + expr_item_str=expr_item_str, + ) + for (kpi, expr_item_str) in location_list + ] + ), + ) + ) + # Income-expense accounts that are not taken in any expression + if missing_account_ids: + errors.append( + self.env._( + "Income/expense account(s) not taken in any expression: %s.", + ", ".join( + [ + acc.display_name + for acc in acc_obj.browse(missing_account_ids) + ] + ), + ) + ) + if errors and raise_if_errors: + raise UserError( + self.env._( + "MIS Report template '%(report)s' is configured as " + "a Profit and Loss report, but it contains the " + "following error(s):\n%(error_list)s", + report=self.display_name, + error_list="\n".join([f"- {error}" for error in errors]), + ) + ) + return errors + + @api.model + def _balance_sheet_check_constraint(self, companies, raise_if_errors=True): + """Validate expressions for a Balance Sheet report. + + - bale/pbale/nbale only + - each account of the company is used exactly once (taking into account) + TODO check signs: start with + (assets) and then only - (equity and liabilities) + """ + aep = self._prepare_aep(companies) + acc_obj = self.env["account.account"].with_company(companies[0]) + errors = [] + bs_account_ids = set( + self.env["account.account"]._search( + [ + ("company_ids", "in", companies.ids), + ("account_type", "not in", ("equity_unaffected", "off_balance")), + ] + ) + ) + account_id2location_per_field = {} + for account_id in bs_account_ids: + account_id2location_per_field[account_id] = { + "bal": [], + "pbal": [], + "nbal": [], + } + for kpi in self.kpi_ids: + for expression in kpi.expression_ids: + expr_items = aep.get_accounting_variables_for_expr(expression.name) + for field, mode, account_ids, expr_item_str in expr_items: + if not account_ids: + continue + if field not in ("bal", "pbal", "nbal") or mode != aep.MODE_END: + errors.append( + self.env._( + "KPI '%(kpi)s' has expression '%(expression_item)s' " + "but only 'bale', 'pbale' and 'nbale' can be used " + "on balance sheet reports.", + kpi=kpi.display_name, + expression_item=expr_item_str, + ) + ) + continue + bad_type_account_ids = account_ids - bs_account_ids + if bad_type_account_ids: + bad_type_accounts = acc_obj.browse(bad_type_account_ids) + errors.append( + self.env._( + "KPI '%(kpi)s' has expression '%(expression_item)s' " + "which uses account(s) with type " + "'Current Year Earnings' or " + "'Off-Balance Sheet': %(accounts)s.", + kpi=kpi.display_name, + expression_item=expr_item_str, + accounts=", ".join( + [acc.display_name for acc in bad_type_accounts] + ), + ) + ) + continue + for account_id in account_ids: + account_id2location_per_field[account_id][field].append( + (kpi.display_name, expr_item_str) + ) + missing_accounts = [] + for account_id, field2location_list in account_id2location_per_field.items(): + if not ( + ( + len(field2location_list["bal"]) == 1 + and not field2location_list["pbal"] + and not field2location_list["nbal"] + ) + or ( + not field2location_list["bal"] + and len(field2location_list["pbal"]) == 1 + and len(field2location_list["nbal"]) == 1 + ) + ): + account = acc_obj.browse(account_id) + if ( + not field2location_list["bal"] + and not field2location_list["pbal"] + and not field2location_list["nbal"] + ): + missing_accounts.append(account.display_name) + else: + locations = [] + for field in ["bal", "pbal", "nbal"]: + if field2location_list[field]: + places = ", ".join( + [ + self.env._( + "KPI '%(kpi)s' " "formula '%(expr_item_str)s'", + kpi=kpi, + expr_item_str=expr_item_str, + ) + for ( + kpi, + expr_item_str, + ) in field2location_list[field] + ] + ) + locations.append( + self.env._( + "as '%(field)s' in %(places)s", + field=field, + places=places, + ) + ) + errors.append( + self.env._( + "Account '%(account)s' is used in the following KPIs: " + "%(locations)s. It can be used only once as 'bale', " + "or once as 'pbale' and once as 'nbale'.", + account=account.display_name, + locations="; ".join(locations), + ) + ) + if missing_accounts: + errors.append( + self.env._( + "Balance sheet account(s) not taken in any expression: %s.", + ", ".join(missing_accounts), + ) + ) + if errors and raise_if_errors: + raise UserError( + self.env._( + "MIS Report template '%(report)s' is configured as a " + "Balance Sheet report, but it contains the " + "following error(s):\n%(error_list)s", + report=self.display_name, + error_list="\n".join([f"- {error}" for error in errors]), + ) + ) + return errors diff --git a/mis_builder/models/mis_report_instance.py b/mis_builder/models/mis_report_instance.py index 9acf6fe63..940dcfeff 100644 --- a/mis_builder/models/mis_report_instance.py +++ b/mis_builder/models/mis_report_instance.py @@ -776,9 +776,14 @@ def get_views(self, views, options=None): result = super().get_views(views, options) return result + def _check_report_constraint(self): + self.ensure_one() + self.report_id._check_constraint(self.query_company_ids) + def preview(self): self.ensure_one() - view_id = self.env.ref("mis_builder." "mis_report_instance_result_view_form") + self._check_report_constraint() + view_id = self.env.ref("mis_builder.mis_report_instance_result_view_form") return { "type": "ir.actions.act_window", "res_model": "mis.report.instance", @@ -791,6 +796,7 @@ def preview(self): def print_pdf(self): self.ensure_one() + self._check_report_constraint() return ( self.env.ref("mis_builder.qweb_pdf_export") .with_context(landscape=self.landscape_pdf) @@ -799,6 +805,7 @@ def print_pdf(self): def export_xls(self): self.ensure_one() + self._check_report_constraint() return self.env.ref("mis_builder.xls_export").report_action( self, data=dict(dummy=True) ) # required to propagate context diff --git a/mis_builder/views/mis_report.xml b/mis_builder/views/mis_report.xml index 2d993a886..8938c053b 100644 --- a/mis_builder/views/mis_report.xml +++ b/mis_builder/views/mis_report.xml @@ -7,6 +7,7 @@ + @@ -17,11 +18,29 @@
- - - - - + + + + + + + + + @@ -111,6 +130,37 @@ + + mis.report.view.search + mis.report + + + + + + + + + + + + mis.report.view.kpi.form mis.report.kpi