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
-- 18.0.1.7.2 (2025-10-29)
-- Bugfixes
+- 19.0.1.1.0 (2026-07-20)
-- 18.0.1.5.0 (2025-10-27)
-- Features
+- 18.0.1.7.2 (2025-10-29)
-- 17.0.1.0.2 (2024-11-11)
@@ -505,19 +509,31 @@
-
+
+
+
+
+- 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.
+
+
+
+
+
-
+
- Fix computation of currency conversion rates
(#737)
-
-
-
-
+
+
+
+
- Introduction of annotations on report cells. Added notes will be
pinted when exporting to PDF and Excel.
@@ -525,26 +541,26 @@
-
-
-
-
+
+
+
+
- Add support for branch companies.
(#648)
-
-
+
+
Bugfixes
- Restore compatibility with python 3.9
(#590)
-
-
+
+
Bugfixes
- Resolve a permission issue when creating report periods with a user
@@ -552,16 +568,16 @@
(#596)
-
-
+
+
Features
- Improve UX by adding the option to edit the pivot date directly on the
view.
-
-
+
+
Features
- Migration to 16.0
@@ -600,24 +616,24 @@
(#415)
-
-
+
+
Bugfixes
- Support users without timezone.
(#388)
-
-
+
+
Bugfixes
- Allow deleting a report that has subreports.
(#431)
-
-
+
+
Bugfixes
- Fix access right issue when clicking the “Save” button on a MIS Report
@@ -625,8 +641,8 @@
(#410)
-
-
+
+
Features
- Remove various field size limits.
@@ -657,8 +673,8 @@
-
-
+
+
Bugfixes
- When on a MIS Report Instance, if you wanted to generate a new line of
@@ -670,16 +686,16 @@
(#361)
-
-
+
+
Bugfixes
- Fix drilldown action name when the account model has been customized.
(#350)
-
-
+
+
Bugfixes
- While duplicating a MIS report instance, comparison columns are
@@ -688,8 +704,8 @@
record. (#343)
-
-
+
+
Features
- The drilldown action name displayed on the breadcrumb has been
@@ -701,8 +717,8 @@
(#320)
-
-
+
+
Bugfixes
- Having a “Compare columns” added on a KPI with an associated style
@@ -716,8 +732,8 @@
#296
-
-
+
+
Bugfixes
- The “Settings” button is now displayed for users with the “Show full
@@ -725,16 +741,16 @@
(#281)
-
-
+
+
Bugfixes
- Fix TypeError: 'module' object is not iterable when using budgets
by account. (#276)
-
-
+
+
Features
- Add column-level filters on analytic account and analytic tags. These
@@ -752,12 +768,12 @@
(#155)
-
-
+
+
Migration to odoo 13.0.
-
-
+
+
Features
- The account_id field of the model selected in ‘Move lines source’
@@ -796,8 +812,8 @@
(#220)
-
-
+
+
Features
- New year-to-date mode for defining periods.
@@ -821,8 +837,8 @@
(#192)
-
-
+
+
Features
Dynamic analytic filters in report preview are not yet available in 11,
this requires an update to the JS widget that proved difficult to
@@ -872,8 +888,8 @@
analytic filters, the underlying model must now have an
analytic_account_id field.
-
-
+
+
- [FIX] Fix bug in company_default_get call returning id instead of
recordset (#103)
@@ -882,16 +898,16 @@
displayed). (#46)
-
-
+
+
- [FIX] Missing comparison operator for AccountingNone leading to errors
in pbal computations
(#93)
-
-
+
+
- [FIX] make subkpi ordering deterministic
(#71)
@@ -905,13 +921,13 @@
(#86)
-
-
+
+
Migration to Odoo 11. No new feature.
(#67)
-
-
+
+
New features:
- [ADD] month and year relative periods, easier to use than date ranges
@@ -948,24 +964,24 @@
- Alternative move line data sources must have a company_id field.
-
-
+
+
Bug fix:
- [FIX] issue with initial balance rounding.
#30
-
-
+
+
Bug fix:
- [FIX] fix error saving KPI on newly created reports.
#18
-
-
+
+
New features:
- [ADD] Alternative move line source per report column. This makes mis
@@ -1010,7 +1026,7 @@
-
+
- [IMP] more robust behaviour in presence of missing expressions
- [FIX] indent style
@@ -1022,24 +1038,24 @@
- [IMP] provide full access to mis builder style for group Adviser.
-
-
+
+
- [IMP] Add refresh button in mis report preview.
- [IMP] Widget code changes to allow to add fields in the widget more
easily.
-
-
+
+
- [IMP] remove unused argument in declare_and_compute_period() for a
cleaner API. This is a breaking API changing merged in urgency before
it is used by other modules.
-
-
+
+
Part of the work for this release has been done at the Sorrento sprint
April 26-29, 2016. The rest (ie a major refactoring) has been done in
the weeks after.
@@ -1087,8 +1103,8 @@
consolidation accounts have been removed
-
-
+
-
-
+
+
Pre-history. Or rather, you need to look at the git log.
-
+
Bugs are tracked on GitHub Issues.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
@@ -1126,15 +1142,15 @@
Do not contact contributors directly about support or help with technical issues.
-
+
-
+
This module is maintained by the OCA.
diff --git a/mis_builder/tests/__init__.py b/mis_builder/tests/__init__.py
index 3a0cafe11..5a59df4c4 100644
--- a/mis_builder/tests/__init__.py
+++ b/mis_builder/tests/__init__.py
@@ -16,3 +16,4 @@
from . import test_target_move
from . import test_utc_midnight
from . import test_mis_report_instance_annotation
+from . import test_detail_groupby
diff --git a/mis_builder/tests/test_detail_groupby.py b/mis_builder/tests/test_detail_groupby.py
new file mode 100644
index 000000000..0bde03bb0
--- /dev/null
+++ b/mis_builder/tests/test_detail_groupby.py
@@ -0,0 +1,161 @@
+# Copyright 2026 Odoo Community Association (OCA)
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
+
+import odoo.tests.common as common
+from odoo import Command
+
+
+class TestMisReportDetailGroupby(common.TransactionCase):
+ """Test generic KPI expansion by a move line field."""
+
+ def _create_move(self, date, amount, debit_acc, credit_acc, partner):
+ move = self.move_model.create(
+ {
+ "journal_id": self.journal.id,
+ "date": date,
+ "partner_id": partner.id if partner else False,
+ "line_ids": [
+ (
+ 0,
+ 0,
+ {
+ "name": "/",
+ "debit": amount,
+ "account_id": debit_acc.id,
+ "partner_id": partner.id if partner else False,
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "name": "/",
+ "credit": amount,
+ "account_id": credit_acc.id,
+ "partner_id": partner.id if partner else False,
+ },
+ ),
+ ],
+ }
+ )
+ move._post()
+ return move
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.company = cls.env["res.company"].create({"name": "Detail Groupby Co"})
+ cls.env.user.company_id = cls.company
+
+ def setUp(self):
+ super().setUp()
+ self.account_model = self.env["account.account"]
+ self.move_model = self.env["account.move"]
+ self.journal_model = self.env["account.journal"]
+ self.partner_a = self.env["res.partner"].create({"name": "Customer A"})
+ self.partner_b = self.env["res.partner"].create({"name": "Customer B"})
+ self.account_ar = self.account_model.create(
+ {
+ "company_ids": [Command.link(self.company.id)],
+ "code": "400AR",
+ "name": "Receivable",
+ "account_type": "asset_receivable",
+ "reconcile": True,
+ }
+ )
+ self.account_in = self.account_model.create(
+ {
+ "company_ids": [Command.link(self.company.id)],
+ "code": "700IN",
+ "name": "Income",
+ "account_type": "income",
+ }
+ )
+ self.journal = self.journal_model.create(
+ {
+ "company_id": self.company.id,
+ "name": "Sale journal",
+ "code": "VEN",
+ "type": "sale",
+ }
+ )
+ self._create_move(
+ "2017-01-10", 10, self.account_ar, self.account_in, self.partner_a
+ )
+ self._create_move(
+ "2017-01-20", 7, self.account_ar, self.account_in, self.partner_b
+ )
+ self._create_move("2017-01-25", 3, self.account_ar, self.account_in, False)
+
+ self.report = self.env["mis.report"].create({"name": "detail groupby report"})
+ self.kpi = self.env["mis.report.kpi"].create(
+ {
+ "report_id": self.report.id,
+ "name": "ar",
+ "description": "Receivable",
+ "expression": "bale[400AR]",
+ "detail_groupby": "partner_id",
+ }
+ )
+ self.instance = self.env["mis.report.instance"].create(
+ {
+ "name": "detail groupby instance",
+ "report_id": self.report.id,
+ "comparison_mode": False,
+ "date_from": "2017-01-01",
+ "date_to": "2017-01-31",
+ }
+ )
+
+ def test_auto_expand_accounts_compat(self):
+ kpi = self.env["mis.report.kpi"].create(
+ {
+ "report_id": self.report.id,
+ "name": "legacy",
+ "description": "Legacy",
+ "expression": "AccountingNone",
+ "auto_expand_accounts": True,
+ }
+ )
+ self.assertEqual(kpi.detail_groupby, "account_id")
+ self.assertTrue(kpi.auto_expand_accounts)
+
+ def test_partner_detail_rows(self):
+ matrix = self.instance._compute_matrix()
+ rows = list(matrix.iter_rows())
+ # parent KPI + 3 partner detail rows (A, B, no partner)
+ self.assertEqual(len(rows), 4)
+ self.assertIsNone(rows[0].detail_id)
+ by_partner = {r.detail_id: r for r in rows[1:]}
+ self.assertEqual(set(by_partner), {self.partner_a.id, self.partner_b.id, 0})
+ for row in rows[1:]:
+ self.assertEqual(row.detail_groupby, "partner_id")
+
+ def cell_val(row):
+ return next(c for c in row.iter_cells() if c).val
+
+ self.assertEqual(cell_val(rows[0]), 20)
+ self.assertEqual(cell_val(by_partner[self.partner_a.id]), 10)
+ self.assertEqual(cell_val(by_partner[self.partner_b.id]), 7)
+ self.assertEqual(cell_val(by_partner[0]), 3)
+
+ def test_drilldown_partner(self):
+ matrix = self.instance._compute_matrix()
+ detail_row = next(
+ r for r in matrix.iter_rows() if r.detail_id == self.partner_a.id
+ )
+ cell = next(c for c in detail_row.iter_cells() if c)
+ action = self.instance.drilldown(cell.drilldown_arg)
+ self.assertEqual(action["res_model"], "account.move.line")
+ self.assertIn(("partner_id", "=", self.partner_a.id), action["domain"])
+ self.assertEqual(cell.drilldown_arg.get("detail_groupby"), "partner_id")
+
+ def test_journal_detail_rows(self):
+ self.kpi.detail_groupby = "journal_id"
+ matrix = self.instance._compute_matrix()
+ rows = list(matrix.iter_rows())
+ self.assertEqual(len(rows), 2)
+ self.assertEqual(rows[1].detail_groupby, "journal_id")
+ self.assertEqual(rows[1].detail_id, self.journal.id)
+ cell = next(c for c in rows[1].iter_cells() if c)
+ self.assertEqual(cell.val, 20)
diff --git a/mis_builder/views/mis_report.xml b/mis_builder/views/mis_report.xml
index 2d993a886..a42cf959c 100644
--- a/mis_builder/views/mis_report.xml
+++ b/mis_builder/views/mis_report.xml
@@ -163,10 +163,14 @@
/>
-
+
+