Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions mis_builder/models/aep.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
283 changes: 283 additions & 0 deletions mis_builder/models/mis_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
9 changes: 8 additions & 1 deletion mis_builder/models/mis_report_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading