diff --git a/mis_builder/README.rst b/mis_builder/README.rst index 57ebb9d6a..b0867be4b 100644 --- a/mis_builder/README.rst +++ b/mis_builder/README.rst @@ -110,6 +110,17 @@ can be found on GitHub. Changelog ========= +19.0.1.1.0 (2026-07-20) +----------------------- + +Features +~~~~~~~~ + +- Add KPI option to expand detail rows by partner (customer/vendor), in + addition to the existing expansion by account. The new ``detail_by`` + field supersedes ``auto_expand_accounts`` while keeping backward + compatibility. + 18.0.1.7.2 (2025-10-29) ----------------------- diff --git a/mis_builder/__manifest__.py b/mis_builder/__manifest__.py index 856b14b5b..6910d336f 100644 --- a/mis_builder/__manifest__.py +++ b/mis_builder/__manifest__.py @@ -3,7 +3,7 @@ { "name": "MIS Builder", - "version": "19.0.1.1.1", + "version": "19.0.1.1.0", "category": "Reporting", "summary": """ Build 'Management Information System' Reports and Dashboards diff --git a/mis_builder/migrations/19.0.1.1.0/post-migration.py b/mis_builder/migrations/19.0.1.1.0/post-migration.py new file mode 100644 index 000000000..15e26f29f --- /dev/null +++ b/mis_builder/migrations/19.0.1.1.0/post-migration.py @@ -0,0 +1,41 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +def migrate(cr, version): + """Map legacy detail expansion flags onto detail_groupby.""" + cr.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'mis_report_kpi' + AND column_name IN ('detail_by', 'detail_groupby', 'auto_expand_accounts') + """ + ) + columns = {row[0] for row in cr.fetchall()} + if "detail_groupby" not in columns: + return + + if "detail_by" in columns: + cr.execute( + """ + UPDATE mis_report_kpi + SET detail_groupby = CASE + WHEN detail_by = 'account' THEN 'account_id' + WHEN detail_by = 'partner' THEN 'partner_id' + WHEN auto_expand_accounts IS TRUE THEN 'account_id' + ELSE detail_groupby + END + WHERE detail_groupby IS NULL + OR detail_groupby = '' + """ + ) + else: + cr.execute( + """ + UPDATE mis_report_kpi + SET detail_groupby = 'account_id' + WHERE auto_expand_accounts IS TRUE + AND (detail_groupby IS NULL OR detail_groupby = '') + """ + ) diff --git a/mis_builder/models/aep.py b/mis_builder/models/aep.py index 94bc33859..3f0ea9434 100644 --- a/mis_builder/models/aep.py +++ b/mis_builder/models/aep.py @@ -313,13 +313,30 @@ def get_account_ids_for_expr(self, expr): account_ids.update(self._account_ids_by_acc_domain[acc_domain]) return account_ids - def get_aml_domain_for_expr(self, expr, date_from, date_to, account_id=None): + def get_aml_domain_for_expr( + self, + expr, + date_from, + date_to, + account_id=None, + detail_groupby=None, + detail_id=None, + ): """Get a domain on account.move.line for an expression. Prerequisite: done_parsing() must have been invoked. Returns a domain that can be used to search on account.move.line. + + ``detail_groupby`` / ``detail_id`` restrict the domain to one detail + value (e.g. partner_id / journal_id). Use ``detail_id=0`` for an empty + many2one. When ``detail_groupby`` is ``account_id``, ``account_id`` + is used instead (same meaning as the dedicated argument). """ + if detail_groupby == "account_id" and account_id is None: + account_id = detail_id + detail_groupby = None + detail_id = None aml_domains = [] date_domain_by_mode = {} for mo in self._ACC_RE.finditer(expr): @@ -335,6 +352,12 @@ def get_aml_domain_for_expr(self, expr, date_from, date_to, account_id=None): aml_domain.append(("account_id", "=", account_id)) else: continue + if detail_groupby is not None and detail_id is not None: + # 0 means empty many2one + if detail_id: + aml_domain.append((detail_groupby, "=", detail_id)) + else: + aml_domain.append((detail_groupby, "=", False)) if field == "crd": aml_domain.append(("credit", "<>", 0.0)) elif field == "deb": @@ -501,6 +524,38 @@ def do_queries( self._data[key][account_id] += initial_data[account_id] self._data[key][account_id] += variation_data[account_id] + def _value_from_entry(self, field, mode, fld_name, entry): + debit = entry.debit + credit = entry.credit + 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 + else: + assert field == "fld" + v = entry.custom_fields[fld_name] + # 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 v + def replace_expr(self, expr): """Replace accounting variables in an expression by their amount. @@ -517,31 +572,9 @@ def f(mo): account_ids = self._account_ids_by_acc_domain[acc_domain] for account_id in account_ids: entry = account_ids_data[account_id] - debit = entry.debit - credit = entry.credit - if field == "bal": - v += debit - credit - elif field == "pbal": - if debit >= credit: - v += debit - credit - elif field == "nbal": - if debit < credit: - v += debit - credit - elif field == "deb": - v += debit - elif field == "crd": - v += credit - else: - assert field == "fld" - v += entry.custom_fields[fld_name] - # 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 + part = self._value_from_entry(field, mode, fld_name, entry) + if part is not AccountingNone: + v += part return "(" + repr(v) + ")" return self._ACC_RE.sub(f, expr) @@ -565,35 +598,7 @@ def f(mo): # here we know account_id is involved in acc_domain account_ids_data = self._data[key] entry = account_ids_data[account_id] - debit = entry.debit - credit = entry.credit - 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 - else: - assert field == "fld" - v = entry.custom_fields[fld_name] - # 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 + v = self._value_from_entry(field, mode, fld_name, entry) return "(" + repr(v) + ")" account_ids = set() @@ -609,6 +614,181 @@ def f(mo): for account_id in account_ids: yield account_id, [self._ACC_RE.sub(f, expr) for expr in exprs] + def do_queries_by_groupby( + self, + groupby_field, + date_from, + date_to, + additional_move_line_filter=None, + aml_model=None, + ): + """Query debit/credit grouped by ``groupby_field`` and account. + + Populates ``_data_groupby[groupby_field]`` for use by + ``replace_exprs_by_groupby``. Must be executed after ``done_parsing()``. + + For many2one fields, empty values are keyed as ``0``. + """ + if groupby_field == "account_id": + raise ValueError( + "account_id details use do_queries() / replace_exprs_by_account_id()" + ) + if not aml_model: + aml_model = self.env["account.move.line"] + else: + aml_model = self.env[aml_model] + aml_model = aml_model.with_context(active_test=False) + if groupby_field not in aml_model._fields: + raise UserError( + self.env._( + 'Unknown detail field "%(field)s" on move line source ' + '"%(model_name)s".', + field=groupby_field, + model_name=aml_model._description, + ) + ) + company_rates = self._get_company_rates(date_to) + if not hasattr(self, "_data_groupby"): + # {groupby_field: {(domain, mode): {detail_id: {account_id: Accum}}}} + self._data_groupby = {} + self._data_groupby[groupby_field] = defaultdict( + lambda: defaultdict( + lambda: defaultdict( + lambda: Accumulator(self._custom_fields), + ) + ) + ) + data = self._data_groupby[groupby_field] + field = aml_model._fields[groupby_field] + domain_by_mode = {} + ends = [] + for key in self._map_account_ids: + domain, mode = key + if mode == self.MODE_END and self.smart_end: + ends.append((domain, mode)) + continue + if mode not in domain_by_mode: + domain_by_mode[mode] = self.get_aml_domain_for_dates( + date_from, date_to, mode + ) + domain = list(domain) + domain_by_mode[mode] + domain.append(("account_id", "in", self._map_account_ids[key])) + if additional_move_line_filter: + domain.extend(additional_move_line_filter) + _logger.debug("read_group detail domain (%s): %s", groupby_field, domain) + try: + accs = aml_model.with_context( + allowed_company_ids=self.companies.ids + )._read_group( + domain, + groupby=(groupby_field, "account_id", "company_id"), + aggregates=( + ( + "debit:sum", + "credit:sum", + *(f"{fname}:sum" for fname in self._custom_fields), + ) + ), + ) + except ValueError as e: + raise UserError( + self.env._( + 'Error while querying move line source "%(model_name)s". ' + "This is likely due to a filter or expression referencing " + "a field that does not exist in the model.\n\n" + "The technical error message is: %(exception)s. ", + model_name=aml_model._description, + exception=e, + ) + ) from e + for ( + detail_value, + account, + company, + debit, + credit, + *custom_fields_sums, + ) in accs: + rate, _dp = company_rates[company.id] + debit = debit or 0.0 + credit = credit or 0.0 + if mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED) and float_is_zero( + debit - credit, precision_digits=self.dp + ): + continue + detail_id = self._groupby_value_to_key(field, detail_value) + account_data = data[key][detail_id][account.id] + account_data.add_debit_credit(debit * rate, credit * rate) + for custom_field, custom_field_sum in zip( + self._custom_fields, custom_fields_sums, strict=True + ): + account_data.add_custom_field( + custom_field, custom_field_sum or AccountingNone + ) + for key in ends: + domain, mode = key + initial_data = data[(domain, self.MODE_INITIAL)] + variation_data = data[(domain, self.MODE_VARIATION)] + detail_ids = set(initial_data.keys()) | set(variation_data.keys()) + for detail_id in detail_ids: + account_ids = set(initial_data[detail_id].keys()) | set( + variation_data[detail_id].keys() + ) + for account_id in account_ids: + data[key][detail_id][account_id] += initial_data[detail_id][ + account_id + ] + data[key][detail_id][account_id] += variation_data[detail_id][ + account_id + ] + + @staticmethod + def _groupby_value_to_key(field, value): + """Normalize a read_group value to a stable detail key.""" + if field.type == "many2one": + return value.id if value else 0 + if value is False or value is None: + return 0 + return value + + def replace_exprs_by_groupby(self, groupby_field, exprs): + """Replace accounting variables iterating by a groupby field. + + yields detail_id, replaced_exprs + + Prerequisite: do_queries_by_groupby() must have been invoked. + For many2one fields, detail id 0 means an empty value. + """ + data = self._data_groupby[groupby_field] + + def f(mo): + field, mode, fld_name, acc_domain, ml_domain = self._parse_match_object(mo) + key = (ml_domain, mode) + detail_data = data[key][detail_id] + v = AccountingNone + for account_id in self._account_ids_by_acc_domain[acc_domain]: + entry = detail_data[account_id] + if not entry.has_data(): + continue + part = self._value_from_entry(field, mode, fld_name, entry) + if part is not AccountingNone: + v += part + return "(" + repr(v) + ")" + + detail_ids = set() + for expr in exprs: + for mo in self._ACC_RE.finditer(expr): + _, mode, _, acc_domain, ml_domain = self._parse_match_object(mo) + key = (ml_domain, mode) + for detail_id, accounts_data in data[key].items(): + for account_id in self._account_ids_by_acc_domain[acc_domain]: + if accounts_data[account_id].has_data(): + detail_ids.add(detail_id) + break + + for detail_id in detail_ids: + yield detail_id, [self._ACC_RE.sub(f, expr) for expr in exprs] + @classmethod def _get_balances(cls, mode, companies, date_from, date_to): expr = f"deb{mode}[], crd{mode}[]" diff --git a/mis_builder/models/expression_evaluator.py b/mis_builder/models/expression_evaluator.py index 309bfc280..fb566f227 100644 --- a/mis_builder/models/expression_evaluator.py +++ b/mis_builder/models/expression_evaluator.py @@ -19,6 +19,7 @@ def __init__( self.additional_move_line_filter = additional_move_line_filter self.aml_model = aml_model self._aep_queries_done = False + self._aep_groupby_queries_done = set() def aep_do_queries(self): if self.aep and not self._aep_queries_done: @@ -30,6 +31,18 @@ def aep_do_queries(self): ) self._aep_queries_done = True + def aep_do_groupby_queries(self, groupby_field): + if not self.aep or groupby_field in self._aep_groupby_queries_done: + return + self.aep.do_queries_by_groupby( + groupby_field, + self.date_from, + self.date_to, + self.additional_move_line_filter, + self.aml_model, + ) + self._aep_groupby_queries_done.add(groupby_field) + def eval_expressions(self, expressions, locals_dict): vals = [] drilldown_args = [] @@ -66,3 +79,37 @@ 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_groupby(self, groupby_field, expressions, locals_dict): + """Evaluate expressions for each distinct value of ``groupby_field``. + + ``account_id`` uses the optimized AEP account path. Any other field on + the move line source is handled via a generic group-by query. + """ + if not self.aep: + return + if groupby_field == "account_id": + yield from self.eval_expressions_by_account(expressions, locals_dict) + return + self.aep_do_groupby_queries(groupby_field) + exprs = [e and e.name or "AccountingNone" for e in expressions] + for detail_id, replaced_exprs in self.aep.replace_exprs_by_groupby( + groupby_field, exprs + ): + vals = [] + drilldown_args = [] + name_error = False + for expr, replaced_expr in zip(exprs, replaced_exprs, strict=True): + val = mis_safe_eval(replaced_expr, locals_dict) + vals.append(val) + if replaced_expr != expr: + drilldown_args.append( + { + "expr": expr, + "detail_groupby": groupby_field, + "detail_id": detail_id, + } + ) + else: + drilldown_args.append(None) + yield detail_id, vals, drilldown_args, name_error diff --git a/mis_builder/models/kpimatrix.py b/mis_builder/models/kpimatrix.py index 63c706d7e..aad6ef488 100644 --- a/mis_builder/models/kpimatrix.py +++ b/mis_builder/models/kpimatrix.py @@ -15,18 +15,19 @@ class KpiMatrixRow: - # TODO: ultimately, the kpi matrix will become ignorant of KPI's and - # accounts and know about rows, columns, sub columns and styles only. - # It is already ignorant of period and only knowns about columns. - # This will require a correct abstraction for expanding row details. + # Detail rows are keyed by (detail_groupby, detail_id), where detail_groupby + # is a field name on the move line source (account_id, partner_id, ...). - def __init__(self, matrix, kpi, account_id=None, parent_row=None): + def __init__( + self, matrix, kpi, detail_id=None, parent_row=None, detail_groupby=None + ): self._matrix = matrix self.kpi = kpi - self.account_id = account_id + self.detail_id = detail_id + self.detail_groupby = detail_groupby self.description = "" self.parent_row = parent_row - if not self.account_id: + if self.detail_id is None: self.style_props = self._matrix._style_model.merge( [self.kpi.report_id.style_id, self.kpi.style_id] ) @@ -35,12 +36,18 @@ def __init__(self, matrix, kpi, account_id=None, parent_row=None): [self.kpi.report_id.style_id, self.kpi.auto_expand_accounts_style_id] ) + @property + def account_id(self): + """Backward-compatible alias for account detail rows.""" + if self.detail_groupby == "account_id": + return self.detail_id + return None + @property def label(self): - if not self.account_id: + if self.detail_id is None: return self.kpi.description - else: - return self._matrix.get_account_name(self.account_id) + return self._matrix.get_detail_name(self.detail_groupby, self.detail_id) def iter_cell_tuples(self, cols=None): if cols is None: @@ -149,12 +156,14 @@ def __init__( lang_model = env["res.lang"] self.lang = lang_model._lang_get(env.user.lang) self._style_model = env["mis.report.style"] + self.account_model_name = account_model self._account_model = env[account_model] + self._aml_model = env["account.move.line"] self._companies = companies # data structures # { kpi: KpiMatrixRow } self._kpi_rows = OrderedDict() - # { kpi: {account_id: KpiMatrixRow} } + # { kpi: {(detail_groupby, detail_id): KpiMatrixRow} } self._detail_rows = {} # { col_key: KpiMatrixCol } self._cols = OrderedDict() @@ -162,8 +171,8 @@ def __init__( self._comparison_todo = defaultdict(list) # { col_key (left of sum): (col_key, [(sign, sum_col_key)]) self._sum_todo = {} - # { account_id: account_name } - self._account_names = {} + # { (detail_groupby, detail_id): display name } + self._detail_names = {} def declare_kpi(self, kpi): """Declare a new kpi (row) in the matrix. @@ -208,30 +217,58 @@ 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( - kpi, col_key, None, vals, drilldown_args, tooltips + self.set_values_detail( + kpi, col_key, None, vals, drilldown_args, tooltips=tooltips ) def set_values_detail_account( self, kpi, col_key, account_id, vals, drilldown_args, tooltips=True ): - """Set values for a kpi and a column and a detail account. + """Backward-compatible wrapper around set_values_detail.""" + return self.set_values_detail( + kpi, + col_key, + account_id, + vals, + drilldown_args, + tooltips=tooltips, + detail_groupby="account_id" if account_id is not None else None, + ) - Invoke this after declaring the kpi and the column. - """ - if not account_id: + def set_values_detail( + self, + kpi, + col_key, + detail_id, + vals, + drilldown_args, + tooltips=True, + detail_groupby=None, + ): + """Set values for a kpi, column and optional detail row.""" + if detail_id is None: row = self._kpi_rows[kpi] else: + if not detail_groupby: + detail_groupby = "account_id" kpi_row = self._kpi_rows[kpi] - if account_id in self._detail_rows[kpi]: - row = self._detail_rows[kpi][account_id] + detail_key = (detail_groupby, detail_id) + if detail_key in self._detail_rows[kpi]: + row = self._detail_rows[kpi][detail_key] else: - row = KpiMatrixRow(self, kpi, account_id, parent_row=kpi_row) - self._detail_rows[kpi][account_id] = row + row = KpiMatrixRow( + self, + kpi, + detail_id, + parent_row=kpi_row, + detail_groupby=detail_groupby, + ) + self._detail_rows[kpi][detail_key] = row col = self._cols[col_key] cell_tuple = [] assert len(vals) == col.colspan assert len(drilldown_args) == col.colspan + style_name = None for val, drilldown_arg, subcol in zip( vals, drilldown_args, col.iter_subcols(), strict=True ): @@ -262,6 +299,7 @@ def set_values_detail_account( row.kpi.style_expression, exc_info=True, ) + style_name = None if style_name: style = self._style_model.search([("name", "=", style_name)]) if style: @@ -410,7 +448,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.detail_id is not None 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) @@ -427,13 +465,14 @@ 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.detail_id, acc, [None] * (len(common_subkpis) or 1), tooltips=False, + detail_groupby=row.detail_groupby, ) def iter_rows(self): @@ -464,12 +503,41 @@ def iter_subcols(self): for col in self.iter_cols(): yield from col.iter_subcols() - def _load_account_names(self): - account_ids = set() + def _load_detail_names(self): + by_field = defaultdict(set) for detail_rows in self._detail_rows.values(): - account_ids.update(detail_rows.keys()) - accounts = self._account_model.search([("id", "in", list(account_ids))]) - self._account_names = {a.id: self._get_account_name(a) for a in accounts} + for detail_groupby, detail_id in detail_rows: + by_field[detail_groupby or "account_id"].add(detail_id) + for detail_groupby, detail_ids in by_field.items(): + if detail_groupby == "account_id": + accounts = self._account_model.search( + [("id", "in", [i for i in detail_ids if i])] + ) + for account in accounts: + self._detail_names[("account_id", account.id)] = ( + self._get_account_name(account) + ) + continue + field = self._aml_model._fields.get(detail_groupby) + if field and field.type == "many2one" and field.comodel_name: + resolved_ids = [i for i in detail_ids if i] + records = self._aml_model.env[field.comodel_name].browse(resolved_ids) + for record in records: + self._detail_names[(detail_groupby, record.id)] = ( + record.display_name + ) + if 0 in detail_ids: + self._detail_names[(detail_groupby, 0)] = self._aml_model.env._( + "(No value)" + ) + else: + for detail_id in detail_ids: + if detail_id in (0, False, None): + self._detail_names[(detail_groupby, detail_id)] = ( + self._aml_model.env._("(No value)") + ) + else: + self._detail_names[(detail_groupby, detail_id)] = str(detail_id) def _get_account_name(self, account): # display_name is account code + account name. Note the account may have @@ -495,16 +563,18 @@ def _get_account_name(self, account): # is bound to multiple companies it does not make sense, because we # don't know to which companies this detail line effectively # contributes, so the list of companies in it would not add useful - # information. To be able to accurately display the company on - # detail lines when the account is bound to multiple companies, - # we'll need a generalized kpi details expansion. + # information. account_name = f"{account_name} [{account_companies.display_name}]" return account_name + def get_detail_name(self, detail_groupby, detail_id): + key = (detail_groupby or "account_id", detail_id) + if key not in self._detail_names: + self._load_detail_names() + return self._detail_names.get(key, "") + def get_account_name(self, account_id): - if account_id not in self._account_names: - self._load_account_names() - return self._account_names[account_id] + return self.get_detail_name("account_id", account_id) def as_dict(self): header = [{"cols": []}, {"cols": []}] @@ -554,8 +624,8 @@ def as_dict(self): "style": self._style_model.to_css_style( cell.style_props, no_indent=True ), - # notes can not be added on 'details by account' lines - "can_be_annotated": not cell.row.account_id, + # notes can not be added on detail lines + "can_be_annotated": cell.row.detail_id is None, } if cell.drilldown_arg: col_data["drilldown_arg"] = cell.drilldown_arg @@ -570,26 +640,69 @@ def as_dict(self): # methods allow us to easily spot where the conversion between the rendering and # semantic domain occur. + @classmethod + def _detail_key_to_cell_token(cls, detail_groupby, detail_id): + # Annotations historically pass False for "no detail"; keep that + # equivalent to None. Detail id 0 (empty many2one) must stay distinct. + if detail_id is None or detail_id is False: + return "" + if not detail_groupby or detail_groupby == "account_id": + # account details keep the historic numeric token for compatibility + return str(detail_id) + return f"d:{detail_groupby}:{detail_id}" + @classmethod def _make_cell_id( - cls, kpi_id: int, account_id: int | None, period_id: int, subkpi_id: int | None + cls, + kpi_id: int, + detail_id: int | None, + period_id: int, + subkpi_id: int | None, + detail_groupby: str | None = None, ) -> str: - return f"{kpi_id}#{account_id or ''}#{period_id}#{subkpi_id or ''}" + mid = cls._detail_key_to_cell_token(detail_groupby, detail_id) + return f"{kpi_id}#{mid}#{period_id}#{subkpi_id or ''}" @classmethod def _pack_cell_id(cls, cell: KpiMatrixCell) -> str: return cls._make_cell_id( cell.row.kpi.id, - cell.row.account_id, + cell.row.detail_id, cell.subcol.col.key, cell.subcol.subkpi and cell.subcol.subkpi.id, + detail_groupby=cell.row.detail_groupby, ) @classmethod def _unpack_cell_id(cls, cell_id: str) -> tuple[int, int | None, int, int | None]: - kpi_id, account_id, col_key, subkpi_id = cell_id.split("#") + """Unpack a cell id. + + The second element is the detail id. Callers that need the groupby + field should use ``_unpack_cell_id_detail``. + """ + kpi_id, detail_id, period_id, subkpi_id = cls._unpack_cell_id_detail(cell_id)[ + :4 + ] + return kpi_id, detail_id, period_id, subkpi_id + + @classmethod + def _unpack_cell_id_detail( + cls, cell_id: str + ) -> tuple[int, int | None, int, int | None, str | None]: + kpi_id, mid, col_key, subkpi_id = cell_id.split("#") kpi_id = int(kpi_id) - account_id = int(account_id) if account_id else None period_id = int(col_key) subkpi_id = int(subkpi_id) if subkpi_id else None - return kpi_id, account_id, period_id, subkpi_id + detail_groupby = None + detail_id = None + if mid.startswith("d:"): + _, detail_groupby, detail_id_s = mid.split(":", 2) + detail_id = int(detail_id_s) if detail_id_s.isdigit() else detail_id_s + elif mid.startswith("p:"): + # legacy partner token from earlier PR revisions + detail_groupby = "partner_id" + detail_id = int(mid[2:]) + elif mid: + detail_groupby = "account_id" + detail_id = int(mid) + return kpi_id, detail_id, period_id, subkpi_id, detail_groupby diff --git a/mis_builder/models/mis_report.py b/mis_builder/models/mis_report.py index 048af8012..70d63c81a 100644 --- a/mis_builder/models/mis_report.py +++ b/mis_builder/models/mis_report.py @@ -97,11 +97,27 @@ class MisReportKpi(models.Model): copy=True, string="Expressions", ) - auto_expand_accounts = fields.Boolean(string="Display details by account") + detail_groupby = fields.Char( + string="Expand details by", + help="Technical name of a field on the move line source model used to " + "expand this KPI into detail rows (for example account_id, partner_id " + "or journal_id). Leave empty for no expansion. " + "account_id uses the optimized accounting expression path; any other " + "field is queried with a generic group-by.", + ) + auto_expand_accounts = fields.Boolean( + string="Display details by account", + compute="_compute_auto_expand_accounts", + inverse="_inverse_auto_expand_accounts", + store=True, + help="Deprecated: set Expand details by = account_id. " + "Kept for backward compatibility.", + ) auto_expand_accounts_style_id = fields.Many2one( - string="Style for account detail rows", + string="Style for detail rows", comodel_name="mis.report.style", required=False, + help="Style applied to expanded detail rows.", ) style_id = fields.Many2one(comodel_name="mis.report.style", required=False) style_expression = fields.Char( @@ -152,6 +168,18 @@ def _compute_display_name(self): for rec in self: rec.display_name = f"{rec.description} ({rec.name})" + @api.depends("detail_groupby") + def _compute_auto_expand_accounts(self): + for rec in self: + rec.auto_expand_accounts = rec.detail_groupby == "account_id" + + def _inverse_auto_expand_accounts(self): + for rec in self: + if rec.auto_expand_accounts: + rec.detail_groupby = "account_id" + elif rec.detail_groupby == "account_id": + rec.detail_groupby = False + @api.constrains("name") def _check_name(self): for record in self: @@ -738,28 +766,32 @@ def _declare_and_compute_col( # noqa: C901 (TODO simplify this fnction) kpi_matrix.set_values(kpi, col_key, vals, drilldown_args) - if ( - name_error - or no_auto_expand_accounts - or not kpi.auto_expand_accounts - ): + detail_groupby = ( + False if no_auto_expand_accounts else kpi.detail_groupby + ) + if name_error or not detail_groupby: continue for ( - account_id, + detail_id, vals, drilldown_args, _name_error, - ) in expression_evaluator.eval_expressions_by_account( - expressions, locals_dict + ) in expression_evaluator.eval_expressions_by_groupby( + detail_groupby, expressions, locals_dict ): 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 + kpi_matrix.set_values_detail( + kpi, + col_key, + detail_id, + vals, + drilldown_args, + detail_groupby=detail_groupby, ) if len(recompute_queue) == 0: @@ -841,7 +873,8 @@ def _declare_and_compute_period( underlying model :param locals_dict: personalized locals dictionary used as evaluation context for the KPI expressions - :param no_auto_expand_accounts: disable expansion of account details + :param no_auto_expand_accounts: disable expansion of detail rows + (account or partner) """ self.ensure_one() diff --git a/mis_builder/models/mis_report_instance.py b/mis_builder/models/mis_report_instance.py index e8dc1f8d2..69c50d0f6 100644 --- a/mis_builder/models/mis_report_instance.py +++ b/mis_builder/models/mis_report_instance.py @@ -542,7 +542,10 @@ def _compute_pivot_date(self): required=False, ) landscape_pdf = fields.Boolean(string="Landscape PDF") - no_auto_expand_accounts = fields.Boolean(string="Disable account details expansion") + no_auto_expand_accounts = fields.Boolean( + string="Disable details expansion", + help="Disable account/partner detail rows for all KPIs of this report.", + ) display_columns_description = fields.Boolean( help="Display the date range details in the column headers." ) @@ -932,7 +935,7 @@ def get_notes_by_cell_id(self) -> dict: return { KpiMatrix._make_cell_id( annotation.kpi_id.id, - False, + None, annotation.period_id.id, annotation.subkpi_id and annotation.subkpi_id.id, ): {"text": annotation.note, "sequence": sequence} @@ -959,6 +962,8 @@ def drilldown(self, arg): period_id = arg.get("period_id") expr = arg.get("expr") account_id = arg.get("account_id") + detail_groupby = arg.get("detail_groupby") + detail_id = arg.get("detail_id") if period_id and expr and AEP.has_account_var(expr): period = self.env["mis.report.instance.period"].browse(period_id) aep = AEP( @@ -971,6 +976,8 @@ def drilldown(self, arg): period.date_from, period.date_to, account_id, + detail_groupby=detail_groupby, + detail_id=detail_id, ) additional_domain = period._get_additional_move_line_filter() if additional_domain: @@ -995,12 +1002,30 @@ def _get_drilldown_action_name(self, arg): period_id = arg.get("period_id") period = self.env["mis.report.instance.period"].browse(period_id) account_id = arg.get("account_id") + detail_groupby = arg.get("detail_groupby") + detail_id = arg.get("detail_id") if account_id: account = self.env[self.report_id.account_model].browse(account_id) return f"{kpi.description} - {account.display_name} - {period.display_name}" - else: - return f"{kpi.description} - {period.display_name}" + if detail_groupby and detail_id is not None: + aml_model = self.env[period.source_aml_model_name or "account.move.line"] + field = aml_model._fields.get(detail_groupby) + if field and field.type == "many2one" and field.comodel_name: + if detail_id: + detail_name = ( + self.env[field.comodel_name].browse(detail_id).display_name + ) + else: + detail_name = self.env._("(No value)") + else: + detail_name = ( + self.env._("(No value)") + if detail_id in (0, False, None) + else str(detail_id) + ) + return f"{kpi.description} - {detail_name} - {period.display_name}" + return f"{kpi.description} - {period.display_name}" def _get_annotation_context(self): """Return the context used to filter annotation linked to this instance.""" diff --git a/mis_builder/readme/HISTORY.md b/mis_builder/readme/HISTORY.md index 5550bf2b6..ccff7cf15 100644 --- a/mis_builder/readme/HISTORY.md +++ b/mis_builder/readme/HISTORY.md @@ -1,3 +1,15 @@ +## 19.0.1.1.0 (2026-07-20) + +### Features + +- Add a generic KPI detail expansion mechanism via ``detail_groupby``: + any field on the move line source (e.g. ``account_id``, ``partner_id``, + ``journal_id``) can be used to expand a KPI into detail rows. + ``account_id`` keeps the optimized AEP path; other fields use a generic + group-by query. ``auto_expand_accounts`` remains supported as a + compatibility alias for ``detail_groupby=account_id``. + + ## 18.0.1.7.2 (2025-10-29) ### Bugfixes diff --git a/mis_builder/static/description/index.html b/mis_builder/static/description/index.html index e597d7b15..565a134e8 100644 --- a/mis_builder/static/description/index.html +++ b/mis_builder/static/description/index.html @@ -388,59 +388,63 @@

MIS Builder

  • Development
  • Known issues / Roadmap
  • Changelog