diff --git a/budget_appropriation_summary/models/budget_appropriation_compilation.py b/budget_appropriation_summary/models/budget_appropriation_compilation.py index 9bcde62ae..5f3c04d52 100644 --- a/budget_appropriation_summary/models/budget_appropriation_compilation.py +++ b/budget_appropriation_summary/models/budget_appropriation_compilation.py @@ -462,6 +462,36 @@ def _compute_amount_totals(self): record.expense_appropriation_ids.mapped("amount_net") ) + def iter_signed_lines(self, budget_type): + """Yield ``(line, sign)`` pairs across this compilation's appropriations. + + ``sign`` is ``+1`` for regular lines and ``-1`` for deduct lines, so + summing ``line.balance * sign`` matches the appropriation's + ``amount_net`` (the value used by the compilation aggregate totals). + """ + self.ensure_one() + field = ( + "revenue_appropriation_ids" + if budget_type == "revenue" + else "expense_appropriation_ids" + ) + for appropriation in self[field]: + for line in appropriation.line_ids: + yield line, 1 + for line in appropriation.deduct_line_ids: + yield line, -1 + + def top_level_department_id(self): + """Return the top-level ancestor id of this compilation's department.""" + self.ensure_one() + department = self.department_analytic_id + if department and department.parent_path: + try: + return int(department.parent_path.strip("/").split("/")[0]) + except (ValueError, IndexError): + return None + return None + @api.depends("revenue_appropriation_ids") def _compute_f4_revenue_data(self): F4Model = self.env["budget.appropriation.f4.report"] diff --git a/budget_appropriation_summary/models/summary_f10w_expense.py b/budget_appropriation_summary/models/summary_f10w_expense.py index e9ff65dec..9fa5094ac 100644 --- a/budget_appropriation_summary/models/summary_f10w_expense.py +++ b/budget_appropriation_summary/models/summary_f10w_expense.py @@ -281,18 +281,17 @@ def _build_activity_expense_totals(self, summary, expense_type_map): Returns: dict: (activity_id, expense_type_code) -> balance """ - lines = summary.expense_appropriation_ids.mapped("line_ids") totals = {} + for compilation in summary.compilation_ids: + for line, sign in compilation.iter_signed_lines("expense"): + activity = line.activity_analytic_id + account_id = line.account_id.id - for line in lines: - activity = line.activity_analytic_id - account_id = line.account_id.id + if not activity or account_id not in expense_type_map: + continue - if not activity or account_id not in expense_type_map: - continue - - expense_type_code = expense_type_map[account_id] - key = (activity.id, expense_type_code) - totals[key] = totals.get(key, 0) + line.balance + expense_type_code = expense_type_map[account_id] + key = (activity.id, expense_type_code) + totals[key] = totals.get(key, 0) + line.balance * sign return totals diff --git a/budget_appropriation_summary/models/summary_f11w_expense.py b/budget_appropriation_summary/models/summary_f11w_expense.py index d74af7c9f..2c2c35701 100644 --- a/budget_appropriation_summary/models/summary_f11w_expense.py +++ b/budget_appropriation_summary/models/summary_f11w_expense.py @@ -223,23 +223,19 @@ def _build_dept_column_totals(self, summary, dept_map, fund_path_map): Returns: dict: (dept_id, column_code) -> balance """ - lines = summary.expense_appropriation_ids.mapped("line_ids") totals = {} - - for line in lines: - top_dept_id = self._extract_top_level_dept_id(line.department_analytic_id) - + for compilation in summary.compilation_ids: + top_dept_id = compilation.top_level_department_id() if not top_dept_id or top_dept_id not in dept_map: continue - - # Check fund - fund = line.fund_analytic_id - if fund: - # Check if fund matches directly or is a child of one of our funds + for line, sign in compilation.iter_signed_lines("expense"): + fund = line.fund_analytic_id + if not fund: + continue fund_code = self._get_fund_code(fund, fund_path_map) if fund_code: key = (top_dept_id, fund_code) - totals[key] = totals.get(key, 0) + line.balance + totals[key] = totals.get(key, 0) + line.balance * sign return totals diff --git a/budget_appropriation_summary/models/summary_f2_revenue.py b/budget_appropriation_summary/models/summary_f2_revenue.py index a65687bd1..85eddd6aa 100644 --- a/budget_appropriation_summary/models/summary_f2_revenue.py +++ b/budget_appropriation_summary/models/summary_f2_revenue.py @@ -145,13 +145,18 @@ def get_data(self, summary_id): } def _build_category_totals(self, summary): - """Build category_code -> balance mapping from all compilations.""" - lines = summary.revenue_appropriation_ids.mapped("line_ids") + """Build category_code -> amount mapping from all compilations. + Signed line sum (line_ids - deduct_line_ids) per compilation so the + grand total matches the compilation's amount_revenue_total (amount_net). + """ account_totals = {} - for line in lines: - acc_id = line.account_id.id - account_totals[acc_id] = account_totals.get(acc_id, 0) + line.balance + for compilation in summary.compilation_ids: + for line, sign in compilation.iter_signed_lines("revenue"): + acc_id = line.account_id.id + account_totals[acc_id] = ( + account_totals.get(acc_id, 0) + line.balance * sign + ) totals = {} for code, _ in self.REVENUE_CATEGORIES: diff --git a/budget_appropriation_summary/models/summary_f4p_revenue.py b/budget_appropriation_summary/models/summary_f4p_revenue.py index 655d1086f..d0cc40c92 100644 --- a/budget_appropriation_summary/models/summary_f4p_revenue.py +++ b/budget_appropriation_summary/models/summary_f4p_revenue.py @@ -117,22 +117,27 @@ def get_data(self, summary_id): } def _build_category_dept_totals(self, summary, dept_map): - lines = summary.revenue_appropriation_ids.mapped("line_ids") + """Build (category_code, top_dept_id) -> signed amount mapping. + Iterates compilations so department is taken from the compilation + (not from individual lines) and deduct_line_ids are subtracted — + keeping totals aligned with compilation.amount_revenue_total. + """ category_accounts = {} for code, _ in self.REVENUE_CATEGORIES: category_accounts[code] = set(self._get_accounts_in_category(code)) totals = {} - for line in lines: - acc_id = line.account_id.id - top_dept_id = self._extract_top_level_dept_id(line.department_analytic_id) - - if top_dept_id and top_dept_id in dept_map: + for compilation in summary.compilation_ids: + top_dept_id = compilation.top_level_department_id() + if not top_dept_id or top_dept_id not in dept_map: + continue + for line, sign in compilation.iter_signed_lines("revenue"): + acc_id = line.account_id.id for code, _ in self.REVENUE_CATEGORIES: if acc_id in category_accounts[code]: key = (code, top_dept_id) - totals[key] = totals.get(key, 0) + line.balance + totals[key] = totals.get(key, 0) + line.balance * sign break return totals diff --git a/budget_appropriation_summary/models/summary_f4w_revenue.py b/budget_appropriation_summary/models/summary_f4w_revenue.py index d8d966828..df936b45c 100644 --- a/budget_appropriation_summary/models/summary_f4w_revenue.py +++ b/budget_appropriation_summary/models/summary_f4w_revenue.py @@ -129,18 +129,20 @@ def get_data(self, summary_id): } def _build_dept_category_totals(self, summary, dept_map, category_accounts): - lines = summary.revenue_appropriation_ids.mapped("line_ids") + """Iterate compilations (not raw appropriations) so dept attribution + comes from the compilation and deduct lines reduce amounts to match + compilation.amount_revenue_total (amount_net).""" totals = {} - - for line in lines: - acc_id = line.account_id.id - top_dept_id = self._extract_top_level_dept_id(line.department_analytic_id) - - if top_dept_id and top_dept_id in dept_map: + for compilation in summary.compilation_ids: + top_dept_id = compilation.top_level_department_id() + if not top_dept_id or top_dept_id not in dept_map: + continue + for line, sign in compilation.iter_signed_lines("revenue"): + acc_id = line.account_id.id for code, _ in self.REVENUE_CATEGORIES: if acc_id in category_accounts[code]: key = (top_dept_id, code) - totals[key] = totals.get(key, 0) + line.balance + totals[key] = totals.get(key, 0) + line.balance * sign break return totals diff --git a/budget_appropriation_summary/models/summary_f5p_expense.py b/budget_appropriation_summary/models/summary_f5p_expense.py index a08f47ab3..2528d08bd 100644 --- a/budget_appropriation_summary/models/summary_f5p_expense.py +++ b/budget_appropriation_summary/models/summary_f5p_expense.py @@ -257,8 +257,6 @@ def _build_category_dept_totals(self, summary, dept_map): Returns: dict: (type_code, cat_name, dept_id) -> balance """ - lines = summary.expense_appropriation_ids.mapped("line_ids") - # Pre-compute account_id -> (type_code, cat_name) mapping account_category_map = {} for type_code, cat_name, mapping_type, codes in self.EXPENSE_CATEGORIES: @@ -266,16 +264,21 @@ def _build_category_dept_totals(self, summary, dept_map): for acc_id in account_ids: account_category_map[acc_id] = (type_code, cat_name) - # Aggregate + # Iterate compilations so department attribution follows the + # compilation (not the line) and deduct_line_ids are subtracted + # — matching compilation.amount_expense_total (amount_net). totals = {} - for line in lines: - acc_id = line.account_id.id - top_dept_id = self._extract_top_level_dept_id(line.department_analytic_id) - - if acc_id in account_category_map and top_dept_id and top_dept_id in dept_map: + for compilation in summary.compilation_ids: + top_dept_id = compilation.top_level_department_id() + if not top_dept_id or top_dept_id not in dept_map: + continue + for line, sign in compilation.iter_signed_lines("expense"): + acc_id = line.account_id.id + if acc_id not in account_category_map: + continue type_code, cat_name = account_category_map[acc_id] key = (type_code, cat_name, top_dept_id) - totals[key] = totals.get(key, 0) + line.balance + totals[key] = totals.get(key, 0) + line.balance * sign return totals diff --git a/budget_appropriation_summary/models/summary_f5w_expense.py b/budget_appropriation_summary/models/summary_f5w_expense.py index 8ae95e76a..2e5a511a0 100644 --- a/budget_appropriation_summary/models/summary_f5w_expense.py +++ b/budget_appropriation_summary/models/summary_f5w_expense.py @@ -144,8 +144,6 @@ def _build_activity_totals(self, summary): Returns: dict: xml_id -> balance """ - lines = summary.expense_appropriation_ids.mapped("line_ids") - # Pre-compute parent_path_prefix -> xml_id mapping # Using parent_path prefix (e.g., "123/") to match all descendants path_prefix_map = {} @@ -157,14 +155,22 @@ def _build_activity_totals(self, summary): else: _logger.warning("Activity plan with xml_id '%s' not found", xml_id) - # Aggregate + # Iterate compilations with signed lines (deducts subtracted) so + # totals reconcile with compilation.amount_expense_total. totals = {} - for line in lines: - activity = line.activity_analytic_id - # Check which plan this activity belongs to - for prefix, xml_id in path_prefix_map.items(): - if prefix in activity.parent_path or activity.parent_path.startswith(prefix): - totals[xml_id] = totals.get(xml_id, 0) + line.balance - break + for compilation in summary.compilation_ids: + for line, sign in compilation.iter_signed_lines("expense"): + activity = line.activity_analytic_id + if not activity or not activity.parent_path: + continue + # parent_path.startswith(prefix) is the only valid ancestor + # check — `prefix in parent_path` is a substring match and + # can collide when ids share digits (e.g. "10/" vs "110/"). + for prefix, xml_id in path_prefix_map.items(): + if activity.parent_path.startswith(prefix): + totals[xml_id] = ( + totals.get(xml_id, 0) + line.balance * sign + ) + break return totals diff --git a/budget_appropriation_summary/models/summary_f7w_expense.py b/budget_appropriation_summary/models/summary_f7w_expense.py index 3445975ca..b68689f67 100644 --- a/budget_appropriation_summary/models/summary_f7w_expense.py +++ b/budget_appropriation_summary/models/summary_f7w_expense.py @@ -209,8 +209,6 @@ def _build_category_totals(self, summary): Returns: dict: (type_code, cat_name) -> balance """ - lines = summary.expense_appropriation_ids.mapped("line_ids") - # Pre-compute account_id -> (type_code, cat_name) mapping account_category_map = {} for type_code, cat_name, mapping_type, codes in self.EXPENSE_CATEGORIES: @@ -218,13 +216,16 @@ def _build_category_totals(self, summary): for acc_id in account_ids: account_category_map[acc_id] = (type_code, cat_name) - # Aggregate + # Iterate compilations with signed lines so deduct_line_ids are + # subtracted — keeping totals aligned with compilation + # amount_expense_total (amount_net). totals = {} - for line in lines: - acc_id = line.account_id.id - if acc_id in account_category_map: - key = account_category_map[acc_id] - totals[key] = totals.get(key, 0) + line.balance + for compilation in summary.compilation_ids: + for line, sign in compilation.iter_signed_lines("expense"): + acc_id = line.account_id.id + if acc_id in account_category_map: + key = account_category_map[acc_id] + totals[key] = totals.get(key, 0) + line.balance * sign return totals diff --git a/budget_appropriation_summary/models/summary_f8w_expense.py b/budget_appropriation_summary/models/summary_f8w_expense.py index e89b86dc3..d8d207fb5 100644 --- a/budget_appropriation_summary/models/summary_f8w_expense.py +++ b/budget_appropriation_summary/models/summary_f8w_expense.py @@ -230,19 +230,19 @@ def _build_dept_activity_totals(self, summary, dept_map, activity_path_map): Returns: dict: (dept_id, xml_id) -> balance """ - lines = summary.expense_appropriation_ids.mapped("line_ids") totals = {} - - for line in lines: - top_dept_id = self._extract_top_level_dept_id(line.department_analytic_id) - activity = line.activity_analytic_id - - if top_dept_id and top_dept_id in dept_map and activity and activity.parent_path: - # Find which activity plan this belongs to + for compilation in summary.compilation_ids: + top_dept_id = compilation.top_level_department_id() + if not top_dept_id or top_dept_id not in dept_map: + continue + for line, sign in compilation.iter_signed_lines("expense"): + activity = line.activity_analytic_id + if not activity or not activity.parent_path: + continue for prefix, xml_id in activity_path_map.items(): - if prefix in activity.parent_path or activity.parent_path.startswith(prefix): + if activity.parent_path.startswith(prefix): key = (top_dept_id, xml_id) - totals[key] = totals.get(key, 0) + line.balance + totals[key] = totals.get(key, 0) + line.balance * sign break return totals diff --git a/budget_appropriation_summary/models/summary_f9w_expense.py b/budget_appropriation_summary/models/summary_f9w_expense.py index ed98b53df..fc29c6719 100644 --- a/budget_appropriation_summary/models/summary_f9w_expense.py +++ b/budget_appropriation_summary/models/summary_f9w_expense.py @@ -241,29 +241,29 @@ def _build_dept_column_totals(self, summary, dept_map, activity_path_map, reserv Returns: dict: (dept_id, column_code) -> balance """ - lines = summary.expense_appropriation_ids.mapped("line_ids") totals = {} - - for line in lines: - top_dept_id = self._extract_top_level_dept_id(line.department_analytic_id) - + for compilation in summary.compilation_ids: + top_dept_id = compilation.top_level_department_id() if not top_dept_id or top_dept_id not in dept_map: continue - - # Check activity plans (4 columns) - activity = line.activity_analytic_id - if activity and activity.parent_path: + for line, sign in compilation.iter_signed_lines("expense"): + # Reserve-fund lines must not also count toward an activity + # plan column, or the department total doubles. Classify + # each line into exactly one column. + if line.account_id and line.account_id.id in reserve_fund_account_ids: + key = (top_dept_id, self.RESERVE_FUND[1]) + totals[key] = totals.get(key, 0) + line.balance * sign + continue + + activity = line.activity_analytic_id + if not activity or not activity.parent_path: + continue for prefix, code in activity_path_map.items(): - if prefix in activity.parent_path or activity.parent_path.startswith(prefix): + if activity.parent_path.startswith(prefix): key = (top_dept_id, code) - totals[key] = totals.get(key, 0) + line.balance + totals[key] = totals.get(key, 0) + line.balance * sign break - # Check reserve fund (1 column) - if line.account_id and line.account_id.id in reserve_fund_account_ids: - key = (top_dept_id, self.RESERVE_FUND[1]) - totals[key] = totals.get(key, 0) + line.balance - return totals def _get_top_level_departments(self):