From c4c60be0a7fb2fb4973102f820a7ae3f42dd6fe5 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Tue, 14 Jul 2026 14:05:24 +0700 Subject: [PATCH] [IMP] budget_control: improve forward balance --- .../report/budget_monitor_report.py | 5 + budget_control/README.rst | 6 +- budget_control/__manifest__.py | 1 - budget_control/models/analytic_account.py | 61 +-- .../models/budget_balance_forward.py | 229 +++++++++- budget_control/models/budget_control.py | 86 ++-- budget_control/models/budget_period.py | 9 +- budget_control/models/budget_plan.py | 125 ++++-- .../report/budget_common_monitoring.py | 2 + .../report/budget_monitor_report.py | 65 +++ .../report/budget_monitor_report_view.xml | 7 +- budget_control/security/ir.model.access.csv | 1 - budget_control/static/description/index.html | 30 +- budget_control/tests/test_budget_control.py | 416 +++++++++++++++++- .../views/analytic_account_views.xml | 16 - budget_control/views/budget_control_view.xml | 16 +- budget_control/views/budget_plan_view.xml | 8 +- budget_control/wizards/__init__.py | 1 - .../wizards/analytic_budget_edit.py | 23 - .../wizards/analytic_budget_edit_view.xml | 31 -- .../wizards/budget_commit_forward_info.py | 16 +- .../budget_commit_forward_info_view.xml | 12 - .../report/budget_monitor_report.py | 5 + .../report/budget_monitor_report.py | 5 + budget_plan_detail/models/budget_plan.py | 37 +- .../report/budget_monitor_report.py | 12 + 26 files changed, 975 insertions(+), 250 deletions(-) delete mode 100644 budget_control/wizards/analytic_budget_edit.py delete mode 100644 budget_control/wizards/analytic_budget_edit_view.xml diff --git a/budget_activity/report/budget_monitor_report.py b/budget_activity/report/budget_monitor_report.py index b34f5e7f..e9931821 100644 --- a/budget_activity/report/budget_monitor_report.py +++ b/budget_activity/report/budget_monitor_report.py @@ -9,6 +9,11 @@ class BudgetMonitorReport(models.Model): activity = fields.Char() + def _select_forward_balance_extra(self): + select = super()._select_forward_balance_extra() + select[20] = "null::char as activity" + return select + # Budget def _select_budget(self): select_budget_query = super()._select_budget() diff --git a/budget_control/README.rst b/budget_control/README.rst index 984aeb74..3bbf5c4f 100644 --- a/budget_control/README.rst +++ b/budget_control/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============== Budget Control ============== @@ -17,7 +13,7 @@ Budget Control .. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png :target: https://odoo-community.org/page/development-status :alt: Alpha -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-ecosoft--odoo%2Fbudgeting-lightgray.png?logo=github diff --git a/budget_control/__manifest__.py b/budget_control/__manifest__.py index d9481da7..630a7c5d 100644 --- a/budget_control/__manifest__.py +++ b/budget_control/__manifest__.py @@ -26,7 +26,6 @@ "data/budget_data.xml", "data/sequence_data.xml", # Wizards - "wizards/analytic_budget_edit_view.xml", "wizards/analytic_budget_info_view.xml", "wizards/confirm_state_budget_view.xml", "wizards/budget_commit_forward_info_view.xml", diff --git a/budget_control/models/analytic_account.py b/budget_control/models/analytic_account.py index 0d01c3b0..1e166b52 100644 --- a/budget_control/models/analytic_account.py +++ b/budget_control/models/analytic_account.py @@ -1,6 +1,8 @@ # Copyright 2021 Ecosoft Co., Ltd. (http://ecosoft.co.th) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from collections import defaultdict + from dateutil.relativedelta import relativedelta from odoo import api, fields, models @@ -59,6 +61,16 @@ class AccountAnalyticAccount(models.Model): compute="_compute_amount_budget_info", help="Sum of amount plan", ) + amount_forward_in = fields.Monetary( + string="Forward In", + compute="_compute_amount_budget_info", + help="Available budget carried in from another budget period.", + ) + amount_forward_out = fields.Monetary( + string="Forward Out", + compute="_compute_amount_budget_info", + help="Available budget carried out, shown as a positive amount.", + ) amount_consumed = fields.Monetary( string="Consumed", compute="_compute_amount_budget_info", @@ -67,13 +79,7 @@ class AccountAnalyticAccount(models.Model): amount_balance = fields.Monetary( string="Available", compute="_compute_amount_budget_info", - help="Available = Total Budget - Consumed", - ) - initial_available = fields.Monetary( - copy=False, - readonly=True, - tracking=True, - help="Initial Balance come from carry forward available accumulated", + help="Available = Total Budget - Forward Out - Consumed", ) initial_commit = fields.Monetary( string="Initial Commitment", @@ -182,23 +188,36 @@ def _compute_amount_budget_info(self): admin_uid = self.env.ref("base.user_admin").id dataset_all = MonitorReport.with_user(admin_uid).read_group( domain=domain, - fields=["analytic_account_id", "amount_type", "amount"], + fields=[ + "analytic_account_id", + "amount_type", + "amount", + ], groupby=["analytic_account_id", "amount_type"], lazy=False, ) + dataset_map = defaultdict(list) + for data in dataset_all: + dataset_map[data["analytic_account_id"][0]].append(data) + forward_map = self.env["budget.balance.forward.line"]._get_forward_balance_map( + ctx.get("budget_period_ids", []), analytic_ids + ) + forward_totals = defaultdict(float) + for (_period_id, analytic_id), amount in forward_map.items(): + forward_totals[analytic_id] += amount for rec in self: - # Filter according to budget_control parameter - dataset = list( - filter( - lambda dataset: rec._filter_by_analytic_account(dataset), - dataset_all, - ) - ) # Get data from dataset + dataset = [ + data + for data in dataset_map[rec.id] + if rec._filter_by_analytic_account(data) + ] budget_info = BudgetPeriod.get_budget_info_from_dataset(query, dataset) rec.amount_budget = budget_info["amount_budget"] + rec.amount_forward_in = forward_totals[rec.id] + rec.amount_forward_out = budget_info["amount_forward_out"] rec.amount_consumed = budget_info["amount_consumed"] - rec.amount_balance = rec.amount_budget - rec.amount_consumed + rec.amount_balance = budget_info["amount_balance"] def _find_next_analytic(self, next_date_range): self.ensure_one() @@ -308,13 +327,3 @@ def _auto_adjust_date_commit(self, docline): docline.date_commit = rec.bm_date_from elif rec.bm_date_to and rec.bm_date_to < docline.date_commit: docline.date_commit = rec.bm_date_to - - def action_edit_initial_available(self): - return { - "name": self.env._("Edit Analytic Budget"), - "type": "ir.actions.act_window", - "res_model": "analytic.budget.edit", - "view_mode": "form", - "target": "new", - "context": {"default_initial_available": self.initial_available}, - } diff --git a/budget_control/models/budget_balance_forward.py b/budget_control/models/budget_balance_forward.py index 7c4bb060..68cdc0f4 100644 --- a/budget_control/models/budget_balance_forward.py +++ b/budget_control/models/budget_balance_forward.py @@ -205,17 +205,16 @@ def _get_forward_initial_balance(self): self.ensure_one() def get_amount(k, v): - forwards = self.env["budget.balance.forward.line"].read_group( + forwards = self.env["budget.balance.forward.line"]._read_group( [ ("forward_id", "=", self.id), ("forward_id.state", "in", ["review", "done"]), (k, "!=", False), ], - [k, v], [k], - orderby=v, + [f"{v}:sum"], ) - return {f[k][0]: f[v] for f in forwards} + return {analytic.id: amount for analytic, amount in forwards} # From to_analytic_account_id res_a = get_amount("to_analytic_account_id", "amount_balance_forward") @@ -233,23 +232,144 @@ def get_amount(k, v): ] return res - def _do_update_initial_avaliable(self): - """Update all Analytic Account's initial commit value - related to budget period""" - self.ensure_one() - # Reset all lines - Analytic = self.env["account.analytic.account"] - analytic_carry_forward = self.forward_line_ids.mapped("to_analytic_account_id") - analytic_accumulate = self.forward_line_ids.mapped( + def _invalidate_forward_budget_records(self): + """Invalidate every non-stored total affected by these documents.""" + lines = self.mapped("forward_line_ids") + if not lines: + return + source_analytics = lines.mapped("analytic_account_id") + target_analytics = lines.mapped("to_analytic_account_id") + lines.mapped( "accumulate_analytic_account_id" ) - analytics = analytic_carry_forward + analytic_accumulate - analytics.write({"initial_available": 0.0}) - # -- - forward_vals = self._get_forward_initial_balance() - for val in forward_vals: - analytic = Analytic.browse(val["analytic_account_id"]) - analytic.initial_available = val["initial_available"] + source_periods = self.mapped("from_budget_period_id") + target_periods = self.mapped("to_budget_period_id") + + PlanLine = self.env["budget.plan.line"].sudo() + plan_lines = PlanLine.search( + [ + ("budget_period_id", "in", target_periods.ids), + ("analytic_account_id", "in", target_analytics.ids), + ] + ) + plan_lines.invalidate_recordset(["amount_forward_in", "allocated_amount"]) + plans = plan_lines.mapped("plan_id") + plans.invalidate_recordset(["total_amount"]) + + if "budget.plan.line.detail" in self.env: + detail_plans = ( + self.env["budget.plan.line.detail"] + .sudo() + .search( + [ + ("plan_id.budget_period_id", "in", target_periods.ids), + ("analytic_account_id", "in", target_analytics.ids), + ] + ) + .mapped("plan_id") + ) + detail_plans.invalidate_recordset(["total_amount"]) + + controls = ( + self.env["budget.control"] + .sudo() + .search( + [ + ("budget_period_id", "in", (source_periods + target_periods).ids), + ( + "analytic_account_id", + "in", + (source_analytics + target_analytics).ids, + ), + ] + ) + ) + controls.invalidate_recordset( + [ + "amount_budget", + "amount_forward_in", + "amount_new_budget", + "amount_forward_out", + "amount_actual", + "amount_commit", + "amount_consumed", + "amount_balance", + ] + ) + (source_analytics + target_analytics).invalidate_recordset( + [ + "amount_budget", + "amount_forward_in", + "amount_forward_out", + "amount_consumed", + "amount_balance", + ] + ) + + def _check_can_cancel(self): + """Prevent cancelling after the target budget entered its workflow.""" + target_periods = self.mapped("to_budget_period_id") + target_analytics = self.mapped("forward_line_ids.to_analytic_account_id") + target_analytics += self.mapped( + "forward_line_ids.accumulate_analytic_account_id" + ) + plans = ( + self.env["budget.plan"] + .sudo() + .search( + [ + ("budget_period_id", "in", target_periods.ids), + ("line_ids.analytic_account_id", "in", target_analytics.ids), + ("state", "in", ["confirm", "done"]), + ] + ) + ) + controls = ( + self.env["budget.control"] + .sudo() + .search( + [ + ("budget_period_id", "in", target_periods.ids), + ("analytic_account_id", "in", target_analytics.ids), + ] + ) + ) + for rec in self: + rec_target_analytics = rec.forward_line_ids.mapped( + "to_analytic_account_id" + ) + rec.forward_line_ids.mapped("accumulate_analytic_account_id") + if not rec_target_analytics: + continue + rec_plans = plans.filtered( + lambda plan, + period=rec.to_budget_period_id, + analytics=rec_target_analytics: plan.budget_period_id == period + and plan.line_ids.mapped("analytic_account_id") & analytics + ) + rec_controls = controls.filtered( + lambda control, + period=rec.to_budget_period_id, + analytics=rec_target_analytics: control.budget_period_id == period + and control.analytic_account_id in analytics + ) + submitted_controls = rec_controls.filtered( + lambda control: control.state in ("submit", "done") + ) + distributed_controls = rec_controls.filtered( + lambda control: control.line_ids + and not control.currency_id.is_zero(control.amount_budget) + ) + used_controls = rec_controls.filtered( + lambda control: not control.currency_id.is_zero(control.amount_consumed) + ) + if rec_plans or submitted_controls or distributed_controls or used_controls: + raise UserError( + _( + "You cannot cancel this Forward Balance because its target " + "budget has already been confirmed, submitted, controlled, " + "or consumed. Use the Reverse Forward process to preserve " + "the audit trail." + ) + ) def action_budget_balance_forward(self): # For extend mode, make sure bm_date_to is extended @@ -261,22 +381,60 @@ def action_budget_balance_forward(self): ) # -- self.write({"state": "done"}) - self._do_update_initial_avaliable() def action_cancel(self): self.write({"state": "cancel"}) - self._do_update_initial_avaliable() def action_draft(self): + self.filtered(lambda forward: forward.state == "done")._check_can_cancel() self.mapped("forward_line_ids").unlink() self.write({"state": "draft"}) - self._do_update_initial_avaliable() + + def write(self, vals): + if vals.get("state") == "cancel": + self._check_can_cancel() + period_changed = {"from_budget_period_id", "to_budget_period_id"} & vals.keys() + if period_changed: + self._invalidate_forward_budget_records() + res = super().write(vals) + if period_changed or "state" in vals: + self._invalidate_forward_budget_records() + return res class BudgetBalanceForwardLine(models.Model): _name = "budget.balance.forward.line" _description = "Budget Balance Forward Line" + @api.model + def _get_forward_balance_map(self, period_ids, analytic_ids): + """Return completed forward balances keyed by period and analytic. + + Both destination fields are deliberately handled here so callers use + the same period-aware source of truth. + """ + result = Counter() + if not analytic_ids: + return result + for analytic_field, amount_field in ( + ("to_analytic_account_id", "amount_balance_forward"), + ("accumulate_analytic_account_id", "amount_balance_accumulate"), + ): + domain = [ + ("forward_id.state", "=", "done"), + (analytic_field, "in", analytic_ids), + ] + if period_ids: + domain.append(("forward_id.to_budget_period_id", "in", period_ids)) + groups = self.sudo()._read_group( + domain, + ["forward_id", analytic_field], + [f"{amount_field}:sum"], + ) + for forward, analytic, amount in groups: + result[(forward.to_budget_period_id.id, analytic.id)] += amount + return result + forward_id = fields.Many2one( comodel_name="budget.balance.forward", string="Forward Balance", @@ -335,6 +493,31 @@ class BudgetBalanceForwardLine(models.Model): store=True, ) + @api.model_create_multi + def create(self, vals_list): + lines = super().create(vals_list) + lines.mapped("forward_id")._invalidate_forward_budget_records() + return lines + + def write(self, vals): + forwards = self.mapped("forward_id") + scope_changed = { + "forward_id", + "analytic_account_id", + "to_analytic_account_id", + "accumulate_analytic_account_id", + } & vals.keys() + if scope_changed: + forwards._invalidate_forward_budget_records() + res = super().write(vals) + (forwards + self.mapped("forward_id"))._invalidate_forward_budget_records() + return res + + def unlink(self): + forwards = self.mapped("forward_id") + forwards._invalidate_forward_budget_records() + return super().unlink() + @api.constrains("amount_balance_forward", "amount_balance_accumulate") def _check_amount(self): for rec in self: @@ -375,7 +558,7 @@ def _compute_to_analytic_account_id(self): auto_create=False ) - @api.depends("amount_balance_forward") + @api.depends("amount_balance", "amount_balance_forward") def _compute_amount_balance_accumulate(self): for rec in self: if rec.amount_balance <= 0: diff --git a/budget_control/models/budget_control.py b/budget_control/models/budget_control.py index cd5e9bb6..6711a6e4 100644 --- a/budget_control/models/budget_control.py +++ b/budget_control/models/budget_control.py @@ -101,16 +101,26 @@ class BudgetControl(models.Model): compute="_compute_diff_amount", help="Diff from Released - Budget", ) - # Total Amount - amount_initial = fields.Monetary( - string="Initial Balance", - compute="_compute_initial_balance", - ) amount_budget = fields.Monetary( string="Budget", compute="_compute_budget_info", help="Sum of amount plan", ) + amount_forward_in = fields.Monetary( + string="Opening Balance", + compute="_compute_budget_info", + help="Available budget carried in from a completed forward balance.", + ) + amount_new_budget = fields.Monetary( + string="New Budget", + compute="_compute_budget_info", + help="New Budget = Budget - Forward Balance.", + ) + amount_forward_out = fields.Monetary( + string="Forward Out", + compute="_compute_budget_info", + help="Budget carried to another period, shown as a positive amount.", + ) amount_actual = fields.Monetary( string="Actual", compute="_compute_budget_info", @@ -129,7 +139,7 @@ class BudgetControl(models.Model): amount_balance = fields.Monetary( string="Available", compute="_compute_budget_info", - help="Available = Total Budget - Consumed", + help="Available Balance = Budget - Forward Out - Consumed.", ) template_id = fields.Many2one( comodel_name="budget.template", @@ -223,14 +233,6 @@ def _check_currency_vs_companies(self): ) ) - @api.depends("analytic_account_id") - def _compute_initial_balance(self): - for rec in self: - rec.amount_initial = ( - rec.analytic_account_id.initial_available - + rec.analytic_account_id.initial_commit - ) - @api.constrains("line_ids") def _check_budget_control_over_consumed(self): BudgetPeriod = self.env["budget.period"] @@ -326,15 +328,36 @@ def _get_query_dataset_all(self): ) return query, dataset_all - @api.depends("line_ids.amount") + @api.depends( + "line_ids.amount", + "line_ids.analytic_account_id", + "analytic_account_id", + "budget_period_id", + ) def _compute_budget_info(self): BudgetPeriod = self.env["budget.period"] query, dataset_all = self._get_query_dataset_all() + dataset_map = defaultdict(list) + for data in dataset_all: + key = (data["budget_period_id"][0], data["analytic_account_id"][0]) + dataset_map[key].append(data) + forward_map = self.env["budget.balance.forward.line"]._get_forward_balance_map( + self.mapped("budget_period_id").ids, + self.mapped("analytic_account_id").ids, + ) for rec in self: - # Filter according to budget_control parameter - dataset = [x for x in dataset_all if rec._filter_by_budget_control(x)] # Get data from dataset + key = (rec.budget_period_id.id, rec.analytic_account_id.id) + dataset = [ + data for data in dataset_map[key] if rec._filter_by_budget_control(data) + ] budget_info = BudgetPeriod.get_budget_info_from_dataset(query, dataset) + budget_info["amount_forward_in"] = forward_map[ + (rec.budget_period_id.id, rec.analytic_account_id.id) + ] + budget_info["amount_new_budget"] = ( + budget_info["amount_budget"] - budget_info["amount_forward_in"] + ) rec.update(budget_info) def _get_lines_init_date(self): @@ -361,7 +384,12 @@ def do_init_budget_commit(self, init): q["amount"] for q in query_data if q["amount"] is not None - and q["amount_type"] not in ["10_budget", "80_actual"] + and q["amount_type"] + not in [ + "10_budget", + "12_forward_out", + "80_actual", + ] ) line.update({"amount": abs(balance_commit)}) @@ -386,21 +414,6 @@ def _check_budget_amount(self): "released amount {amount:,.2f} {symbol}" ).format(amount=rec.released_amount, symbol=rec.currency_id.symbol) ) - # Check plan vs intial - if ( - float_compare( - rec.amount_initial, - rec.amount_budget, - precision_rounding=rec.currency_id.rounding, - ) - == 1 - ): - raise UserError( - self.env._( - "Planning amount should be greater than " - "initial balance {amount:,.2f} {symbol}" - ).format(amount=rec.amount_initial, symbol=rec.currency_id.symbol) - ) def action_draft(self): return self.write({"state": "draft"}) @@ -471,10 +484,13 @@ def prepare_budget_control_matrix(self): self.env["budget.control.line"].create(items) def _get_domain_budget_monitoring(self): - return [("analytic_account_id", "=", self.analytic_account_id.id)] + return [ + ("analytic_account_id", "=", self.analytic_account_id.id), + ("budget_period_id", "=", self.budget_period_id.id), + ] def _get_context_budget_monitoring(self): - ctx = {"search_default_group_by_analytic_account": 1} + ctx = {"group_by": ["budget_period_id", "analytic_account_id"]} return ctx def action_view_monitoring(self): diff --git a/budget_control/models/budget_period.py b/budget_control/models/budget_period.py index 0a4d5187..23fbd0d9 100644 --- a/budget_control/models/budget_period.py +++ b/budget_control/models/budget_period.py @@ -548,6 +548,10 @@ def get_budget_info_from_dataset(self, query, dataset): if is_commit: budget_info[col] = -amount # Negate budget_info["amount_commit"] += budget_info[col] + elif amount_type == "12_forward_out": + # The monitoring dataset stores outflows as negative ledger + # amounts. Present Forward Out as a positive business amount. + budget_info[col] = -amount elif amount_type == "80_actual": # Negate consumed budget_info[col] = -amount else: @@ -556,7 +560,9 @@ def get_budget_info_from_dataset(self, query, dataset): budget_info["amount_commit"] + budget_info["amount_actual"] ) budget_info["amount_balance"] = ( - budget_info["amount_budget"] - budget_info["amount_consumed"] + budget_info["amount_budget"] + - budget_info["amount_forward_out"] + - budget_info["amount_consumed"] ) return budget_info @@ -567,6 +573,7 @@ def _budget_info_query(self): "10_budget", False, ), # (amount_type, is_commit) + "amount_forward_out": ("12_forward_out", False), "amount_actual": ("80_actual", False), }, "fields": [ diff --git a/budget_control/models/budget_plan.py b/budget_control/models/budget_plan.py index 09370299..5a459244 100644 --- a/budget_control/models/budget_plan.py +++ b/budget_control/models/budget_plan.py @@ -1,9 +1,8 @@ # Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - from odoo import Command, api, fields, models -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.tools import float_compare @@ -78,10 +77,16 @@ def _compute_currency_id(self): rec.currency_id = next(iter(currencies), self.env.company.currency_id) - @api.depends("line_ids") + @api.depends( + "line_ids.amount", + "line_ids.amount_forward_in", + "line_ids.allocated_amount", + "line_ids.analytic_account_id", + "budget_period_id", + ) def _compute_total_amount(self): for rec in self: - rec.total_amount = sum(rec.line_ids.mapped("amount")) + rec.total_amount = sum(rec.line_ids.mapped("allocated_amount")) @api.depends("line_ids") def _compute_budget_control(self): @@ -136,9 +141,17 @@ def _update_budget_control_values(self): plan_line = self.line_ids.with_context(active_test=False) dp = self.currency_id.decimal_places for line in plan_line: - budget_control = line.budget_control_ids.filtered("active") + budget_control = line.budget_control_ids.filtered( + lambda control, line=line: control.active + and control.budget_period_id == line.budget_period_id + ) if not budget_control: - budget_control = line.budget_control_ids.sorted("id")[-1:] + budget_control = line.budget_control_ids.filtered( + lambda control, line=line: control.budget_period_id + == line.budget_period_id + ).sorted("id")[-1:] + if not budget_control: + continue if ( float_compare( budget_control.allocated_amount, @@ -160,10 +173,19 @@ def _update_budget_control_values(self): def action_create_update_budget_control(self): self.ensure_one() analytic_plan = self.line_ids.mapped("analytic_account_id") - # Skip if budget control already exists - existing_budget_controls = self.with_context( - active_test=False - ).budget_control_ids + # A budget control is unique in the scope of a budget period and an + # analytic account. The same analytic may legitimately have a budget + # control in every year (Extend carry-forward). + existing_budget_controls = ( + self.env["budget.control"] + .with_context(active_test=False) + .search( + [ + ("budget_period_id", "=", self.budget_period_id.id), + ("analytic_account_id", "in", analytic_plan.ids), + ] + ) + ) existing_analytics = existing_budget_controls.mapped("analytic_account_id") new_analytic = analytic_plan - existing_analytics @@ -192,11 +214,17 @@ def action_create_update_budget_control(self): def check_plan_consumed(self): prec_digits = self.currency_id.decimal_places for line in self.mapped("line_ids"): - amount = line.amount - # Check amount + transferred is less than the amount consumed + amount = line.allocated_amount + active_controls = line.budget_control_ids.filtered( + lambda control, line=line: control.active + and control.budget_period_id == line.budget_period_id + ) + transferred_amount = sum(active_controls.mapped("transferred_amount")) + released_amount = amount + transferred_amount + # Check allocated + transfers against the amount already consumed. if ( float_compare( - amount + line.budget_control_ids.transferred_amount, + released_amount, line.amount_consumed, precision_digits=prec_digits, ) @@ -208,23 +236,20 @@ def check_plan_consumed(self): f"has amount less than consumed." ) ) - # Update allocated/released if changed - if line.allocated_amount != amount or line.released_amount != amount: - # NOTE: If lines are large, this can change to direct SQL - # to update the plan line - line.write( - { - "allocated_amount": amount, - "released_amount": amount, - } - ) + # Released is allocated plus the current transfers. Allocated is + # computed directly from New Budget + Forward Balance. + if line.released_amount != released_amount: + line.released_amount = released_amount def action_update_amount_consumed(self): """Update amount consumed and released from budget control""" for rec in self: for line in rec.line_ids: # find consumed amount from budget control - active_control = line.budget_control_ids + active_control = line.budget_control_ids.filtered( + lambda control, line=line: control.active + and control.budget_period_id == line.budget_period_id + ) if not active_control: continue @@ -244,7 +269,8 @@ def _prepare_update_plan_lines(self, analytics): active_control = analytic.budget_control_ids.filtered( lambda control, self=self: control.budget_period_id == self.budget_period_id - ) + and control.active + ).sorted("id")[-1:] lines.append( Command.create( { @@ -335,9 +361,18 @@ class BudgetPlanLine(models.Model): comodel_name="account.analytic.account", required=True, ) - allocated_amount = fields.Monetary(string="Allocated") + allocated_amount = fields.Monetary( + string="Allocated", + compute="_compute_budget_amounts", + help="New Budget + Forward Balance available for allocation.", + ) released_amount = fields.Monetary(string="Released") - amount = fields.Monetary(string="New Amount") + amount = fields.Monetary(string="New Budget") + amount_forward_in = fields.Monetary( + string="Forward Balance", + compute="_compute_budget_amounts", + help="Available budget carried in from a completed forward balance.", + ) amount_consumed = fields.Monetary(string="Consumed") company_ids = fields.Many2many( comodel_name="res.company", related="analytic_account_id.budget_company_ids" @@ -349,10 +384,42 @@ class BudgetPlanLine(models.Model): default=True, help="Activate/Deactivate when create/Update Budget Control" ) - @api.depends("analytic_account_id.budget_control_ids") + @api.depends("amount", "analytic_account_id", "budget_period_id") + def _compute_budget_amounts(self): + """Compute the forward balance and the total amount to allocate.""" + period_ids = self.mapped("budget_period_id").ids + analytic_ids = self.mapped("analytic_account_id").ids + amounts = self.env["budget.balance.forward.line"]._get_forward_balance_map( + period_ids, analytic_ids + ) + for rec in self: + rec.amount_forward_in = amounts[ + (rec.budget_period_id.id, rec.analytic_account_id.id) + ] + rec.allocated_amount = rec.amount + rec.amount_forward_in + + @api.constrains("amount") + def _check_amount_nonnegative(self): + for rec in self: + if ( + float_compare( + rec.amount, + 0.0, + precision_rounding=rec.currency_id.rounding, + ) + < 0 + ): + raise ValidationError(self.env._("New Budget cannot be negative.")) + + @api.depends("analytic_account_id.budget_control_ids", "budget_period_id") def _compute_budget_control_ids(self): for rec in self.sudo(): - rec.budget_control_ids = rec.analytic_account_id.budget_control_ids + rec.budget_control_ids = ( + rec.analytic_account_id.budget_control_ids.filtered( + lambda control, rec=rec: control.budget_period_id + == rec.budget_period_id + ) + ) @api.constrains("analytic_account_id") def _check_duplicate_analytic_account(self): diff --git a/budget_control/report/budget_common_monitoring.py b/budget_control/report/budget_common_monitoring.py index 5f311bf5..5773faff 100644 --- a/budget_control/report/budget_common_monitoring.py +++ b/budget_control/report/budget_common_monitoring.py @@ -10,12 +10,14 @@ class BudgetCommonMonitoring(models.AbstractModel): res_id = fields.Reference( selection=lambda self: [("budget.control.line", "Budget Control Lines")] + + [("budget.balance.forward.line", "Budget Balance Forward Line")] + self._get_budget_docline_model(), string="Resource ID", ) reference = fields.Char() amount_type = fields.Selection( selection=lambda self: [("10_budget", "Budget")] + + [("12_forward_out", "Forward Balance Out")] + self._get_budget_amount_type(), string="Type", ) diff --git a/budget_control/report/budget_monitor_report.py b/budget_control/report/budget_monitor_report.py index 478cd10c..a6cbcc93 100644 --- a/budget_control/report/budget_monitor_report.py +++ b/budget_control/report/budget_monitor_report.py @@ -83,14 +83,79 @@ def _get_sql(self) -> SQL: (SELECT %(select_budget)s %(from_budget)s) UNION ALL (SELECT %(select_actual)s %(from_actual)s %(where_actual)s) + UNION ALL + (%(select_forward_balance)s) """, select_budget=SQL(select_budget), from_budget=self._from_budget(), select_actual=SQL(select_actual), from_actual=self._from_statement("80_actual"), where_actual=self._where_actual(), + select_forward_balance=self._select_forward_balance(), ) + @api.model + def _select_forward_balance(self) -> SQL: + """Return only the source-period deduction for monitoring. + + The target matrix already includes Forward Balance, so exposing a + positive Forward In movement would double-count it in pivot totals. + Target composition remains available on Budget Plan and Budget Control. + """ + companies = """ + ( + SELECT string_agg(company.name::text, ', ') + FROM budget_balance_forward_company_rel forward_company + INNER JOIN res_company company + ON company.id = forward_company.company_id + WHERE forward_company.forward_id = forward.id + ) + """ + source_document = """ + forward.name || ' (' || from_period.name || ' -> ' || to_period.name || ')' + """ + extra_columns = self._select_forward_balance_extra() + extra_select = ", ".join(extra_columns[key] for key in sorted(extra_columns)) + extra_select = f", {extra_select}" if extra_select else "" + return SQL( + f""" + SELECT + 13000000000 + line.id AS id, + 'budget.balance.forward.line,' || line.id AS res_id, + NULL::integer AS kpi_id, + line.analytic_account_id AS analytic_account_id, + source_analytic.plan_id AS analytic_plan, + from_period.bm_date_to AS date, + '12_forward_out' AS amount_type, + -(line.amount_balance_forward + line.amount_balance_accumulate) + AS amount, + NULL::integer AS product_id, + NULL::integer AS account_id, + forward.name AS reference, + {source_document} AS source_document, + NULL::char AS budget_state, + FALSE AS fwd_commit, + {companies} AS companies, + TRUE AS active + {extra_select} + FROM budget_balance_forward_line line + INNER JOIN budget_balance_forward forward ON forward.id = line.forward_id + INNER JOIN budget_period from_period + ON from_period.id = forward.from_budget_period_id + INNER JOIN budget_period to_period + ON to_period.id = forward.to_budget_period_id + INNER JOIN account_analytic_account source_analytic + ON source_analytic.id = line.analytic_account_id + WHERE forward.state = 'done' + AND (line.amount_balance_forward + line.amount_balance_accumulate) != 0 + """ + ) + + @api.model + def _select_forward_balance_extra(self): + """Hook for extension dimensions on every forward UNION branch.""" + return {} + def _get_select_amount_types(self): sql_select = {} for source in self._get_consumed_sources(): diff --git a/budget_control/report/budget_monitor_report_view.xml b/budget_control/report/budget_monitor_report_view.xml index 39c52001..4a491674 100644 --- a/budget_control/report/budget_monitor_report_view.xml +++ b/budget_control/report/budget_monitor_report_view.xml @@ -63,6 +63,11 @@ string="Budget Cancelled" domain="[('budget_state', 'in', ['cancel', False])]" /> + budget.monitor.report pivot,list,graph { - 'group_by':[], + 'group_by':['budget_period_id', 'analytic_account_id'], 'group_by_no_leaf':1, 'search_default_budget_state_done': True } diff --git a/budget_control/security/ir.model.access.csv b/budget_control/security/ir.model.access.csv index 57a490cd..5e203384 100644 --- a/budget_control/security/ir.model.access.csv +++ b/budget_control/security/ir.model.access.csv @@ -24,7 +24,6 @@ access_budget_commit_forward_line,access_budget_commit_forward_line,model_budget access_budget_balance_forward,access_budget_balance_forward,model_budget_balance_forward,budget_control.group_budget_control_manager,1,1,1,1 access_budget_balance_forward_line,access_budget_balance_forward_line,model_budget_balance_forward_line,budget_control.group_budget_control_manager,1,1,1,1 access_budget_state_confirmation,access_budget_state_confirmation,model_budget_state_confirmation,budget_control.group_budget_control_manager,1,1,1,1 -access_analytic_budget_edit,access_analytic_budget_edit,model_analytic_budget_edit,budget_control.group_budget_control_manager,1,1,1,1 access_analytic_budget_info,access_analytic_budget_info,model_analytic_budget_info,base.group_user,1,0,0,0 access_budget_plan_analytic_select,access_budget_plan_analytic_select,model_budget_plan_analytic_select,budget_control.group_budget_control_manager,1,1,1,1 access_budget_commit_forward_info,access_budget_commit_forward_info,model_budget_commit_forward_info,budget_control.group_budget_control_manager,1,1,1,1 diff --git a/budget_control/static/description/index.html b/budget_control/static/description/index.html index 259b0da6..b5e86b00 100644 --- a/budget_control/static/description/index.html +++ b/budget_control/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Budget Control -
+
+

Budget Control

- - -Odoo Community Association - -
-

Budget Control

-

Alpha License: AGPL-3 ecosoft-odoo/budgeting

+

Alpha License: AGPL-3 ecosoft-odoo/budgeting

This module is the main module from a set of budget control modules. This module alone will allow you to work in full cycle of budget control process. Other modules, each one are the small enhancement of this @@ -382,7 +377,7 @@

Budget Control

describe the full cycle of budget control already provided by this module,

-

Budget Control Core Features:

+

Budget Control Core Features:

  • Budget Commitment (base.budget.move)

    Probably the most crucial part of budget_control.

    @@ -465,7 +460,7 @@

    Budget Control Core Features:

-

Extended Modules:

+

Extended Modules:

Following are brief explanation of what the extended module will do.

Budget Move extension

These modules extend base.budget.move for other document budget @@ -518,7 +513,7 @@

Extended Modules:

-

Usage

+

Usage

Before start using this module, following access right must be set.

  • Budget User for Budget Control Sheet, Budget Report
  • @@ -598,7 +593,7 @@

    Usage

-

Bug Tracker

+

Bug Tracker

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 @@ -606,30 +601,29 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Authors

+

Authors

  • Ecosoft
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

Current maintainers:

kittiu ru3ix-bbb Saran440

This module is part of the ecosoft-odoo/budgeting project on GitHub.

You are welcome to contribute.

-
diff --git a/budget_control/tests/test_budget_control.py b/budget_control/tests/test_budget_control.py index 6419ffac..57d5a2f7 100644 --- a/budget_control/tests/test_budget_control.py +++ b/budget_control/tests/test_budget_control.py @@ -6,7 +6,7 @@ from freezegun import freeze_time from odoo import Command -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.tests import Form, tagged from .common import get_budget_common_class @@ -504,6 +504,20 @@ def test_16_budget_transfer(self): self.assertAlmostEqual(self.budget_control2.released_amount, 2440.0) self.assertAlmostEqual(self.budget_control2.transferred_amount, 40.0) + # Plan-line Allocated remains the original funding while Released + # follows the completed transfers. + self.budget_plan.check_plan_consumed() + plan_line_from = self.budget_plan.line_ids.filtered( + lambda line: line.analytic_account_id == self.costcenter1 + ) + plan_line_to = self.budget_plan.line_ids.filtered( + lambda line: line.analytic_account_id == self.costcenterX + ) + self.assertAlmostEqual(plan_line_from.allocated_amount, 2400.0) + self.assertAlmostEqual(plan_line_from.released_amount, 2360.0) + self.assertAlmostEqual(plan_line_to.allocated_amount, 2400.0) + self.assertAlmostEqual(plan_line_to.released_amount, 2440.0) + # Check snart button budget_control to transfer_items action_transfer_from = self.budget_control.action_open_budget_transfer_item() self.assertEqual(action_transfer_from["res_model"], "budget.transfer.item") @@ -535,6 +549,9 @@ def test_16_budget_transfer(self): self.assertEqual(len(self.budget_control2.transfer_item_ids), 1) self.assertAlmostEqual(self.budget_control2.released_amount, 2400.0) self.assertAlmostEqual(self.budget_control2.transferred_amount, 0.0) + self.budget_plan.check_plan_consumed() + self.assertAlmostEqual(plan_line_from.released_amount, 2400.0) + self.assertAlmostEqual(plan_line_to.released_amount, 2400.0) @freeze_time("2001-02-01") def test_17_budget_adjustment(self): @@ -579,6 +596,403 @@ def test_18_budget_carry_forward(self): budget_commit_forward.action_draft() self.assertEqual(budget_commit_forward.state, "draft") + def test_18a_budget_balance_forward_monitoring(self): + """A completed balance forward must reduce the source availability.""" + BudgetPeriod = self.env["budget.period"] + BalanceForward = self.env["budget.balance.forward"] + BalanceForwardLine = self.env["budget.balance.forward.line"] + MonitorReport = self.env["budget.monitor.report"] + + next_period = BudgetPeriod.create( + { + "name": f"Budget for FY{self.year + 1}", + "template_id": self.template.id, + "bm_date_from": f"{self.year + 1}-01-01", + "bm_date_to": f"{self.year + 1}-12-31", + "plan_date_range_type_id": self.date_range_type.id, + "control_level": "analytic_kpi", + } + ) + forward = BalanceForward.create( + { + "name": "Test: Budget Balance Forward", + "from_budget_period_id": self.budget_period.id, + "to_budget_period_id": next_period.id, + } + ) + forward_line = BalanceForwardLine.create( + { + "forward_id": forward.id, + "analytic_account_id": self.costcenter1.id, + "amount_balance": 30.0, + "amount_balance_forward": 20.0, + "accumulate_analytic_account_id": self.costcenter1.id, + } + ) + next_plan_vals = { + "name": "Budget Plan FY Next", + "budget_period_id": next_period.id, + "line_ids": [ + Command.create( + { + "analytic_account_id": self.costcenter1.id, + "amount": 20.0, + } + ) + ], + } + # budget_plan_detail overrides total_amount to use detail lines until + # the plan is confirmed. This core test intentionally exercises the + # plan-line flow, so make that mode explicit when the optional module + # is installed (as it is in the full CI test suite). + if "is_confirm_plan" in self.BudgetPlan._fields: + next_plan_vals["is_confirm_plan"] = True + next_plan = self.BudgetPlan.create(next_plan_vals) + self.assertEqual(self.budget_control.amount_balance, 2400.0) + self.assertEqual(next_plan.line_ids.amount_forward_in, 0.0) + self.assertEqual(next_plan.line_ids.allocated_amount, 20.0) + + forward.action_budget_balance_forward() + self.assertEqual(self.budget_control.amount_balance, 2370.0) + self.assertEqual(next_plan.line_ids.amount_forward_in, 30.0) + self.assertEqual(next_plan.line_ids.allocated_amount, 50.0) + + # A line change must invalidate cached Plan and Control totals. + forward_line.amount_balance = 40.0 + self.assertEqual(next_plan.line_ids.amount_forward_in, 40.0) + self.assertEqual(next_plan.line_ids.allocated_amount, 60.0) + forward_line.amount_balance = 30.0 + self.assertEqual(next_plan.line_ids.amount_forward_in, 30.0) + self.assertEqual(next_plan.line_ids.allocated_amount, 50.0) + + next_plan.check_plan_consumed() + next_plan.action_create_update_budget_control() + target_control = self.BudgetControl.search( + [ + ("budget_period_id", "=", next_period.id), + ("analytic_account_id", "=", self.costcenter1.id), + ] + ) + self.assertEqual(len(target_control), 1) + self.assertNotEqual(target_control, self.budget_control) + self.assertEqual(target_control.allocated_amount, 50.0) + target_line = self.env["budget.control.line"].create( + { + "budget_control_id": target_control.id, + "analytic_account_id": self.costcenter1.id, + "date_from": next_period.bm_date_from, + "date_to": next_period.bm_date_to, + "amount": 50.0, + } + ) + target_control.allocated_amount = target_line.amount + + self.assertEqual(self.budget_control.amount_forward_out, 30.0) + self.assertEqual(self.budget_control.amount_balance, 2370.0) + self.assertEqual(target_control.amount_forward_in, 30.0) + self.assertEqual(target_control.amount_new_budget, 20.0) + self.assertEqual(target_control.amount_budget, 50.0) + self.assertEqual(target_control.amount_balance, 50.0) + self.assertEqual(next_plan.line_ids.amount_forward_in, 30.0) + self.assertEqual(next_plan.line_ids.allocated_amount, 50.0) + self.assertEqual(next_plan.line_ids.released_amount, 50.0) + self.assertEqual(next_plan.total_amount, 50.0) + target_control.action_submit() + self.assertEqual(target_control.state, "submit") + + carry_only_plan = self.BudgetPlan.create( + { + "name": "Carry Only Budget Plan FY Next", + "budget_period_id": next_period.id, + "line_ids": [ + Command.create( + { + "analytic_account_id": self.costcenter1.id, + "amount": 0.0, + } + ) + ], + } + ) + carry_only_plan.check_plan_consumed() + self.assertEqual(carry_only_plan.line_ids.allocated_amount, 30.0) + target_monitoring = target_control.action_view_monitoring() + self.assertIn( + ("budget_period_id", "=", next_period.id), + target_monitoring["domain"], + ) + + fields = ["budget_period_id", "amount_type", "amount"] + groupby = ["budget_period_id", "amount_type"] + source_data = MonitorReport.read_group( + [ + ("analytic_account_id", "=", self.costcenter1.id), + ("budget_period_id", "=", self.budget_period.id), + ], + fields, + groupby, + lazy=False, + ) + target_data = MonitorReport.read_group( + [ + ("analytic_account_id", "=", self.costcenter1.id), + ("budget_period_id", "=", next_period.id), + ], + fields, + groupby, + lazy=False, + ) + source_amounts = {row["amount_type"]: row["amount"] for row in source_data} + target_amounts = {row["amount_type"]: row["amount"] for row in target_data} + self.assertEqual(source_amounts["12_forward_out"], -30.0) + self.assertNotIn("11_forward_in", target_amounts) + self.assertEqual(target_amounts["10_budget"], 50.0) + self.assertEqual(sum(target_amounts.values()), 50.0) + + with self.assertRaisesRegex(UserError, "Reverse Forward"): + forward.action_cancel() + self.assertEqual(forward.state, "done") + self.assertEqual(self.budget_control.amount_balance, 2370.0) + self.assertEqual(next_plan.line_ids.allocated_amount, 50.0) + + # Cancellation is safe before a target Plan or Control enters workflow. + cancellable_forward = BalanceForward.create( + { + "name": "Test: Cancellable Budget Balance Forward", + "from_budget_period_id": self.budget_period.id, + "to_budget_period_id": next_period.id, + } + ) + BalanceForwardLine.create( + { + "forward_id": cancellable_forward.id, + "analytic_account_id": self.costcenterX.id, + "amount_balance": 10.0, + "amount_balance_forward": 10.0, + } + ) + self.assertEqual(self.budget_control2.amount_balance, 2400.0) + cancellable_forward.action_budget_balance_forward() + self.assertEqual(self.budget_control2.amount_balance, 2390.0) + cancellable_forward.action_cancel() + self.assertEqual(self.budget_control2.amount_balance, 2400.0) + + with self.assertRaisesRegex(ValidationError, "cannot be negative"): + self.BudgetPlan.create( + { + "name": "Invalid Negative Budget", + "budget_period_id": next_period.id, + "line_ids": [ + Command.create( + { + "analytic_account_id": self.costcenterX.id, + "amount": -1.0, + } + ) + ], + } + ) + + @freeze_time("2001-02-01") + def test_18b_balance_forward_same_analytic(self): + """Forward balance on the same analytic reduces source and cancels safely.""" + BalanceForwardLine = self.env["budget.balance.forward.line"] + + next_period = self.env["budget.period"].create( + { + "name": f"Budget for FY{self.year + 1}", + "template_id": self.template.id, + "bm_date_from": f"{self.year + 1}-01-01", + "bm_date_to": f"{self.year + 1}-12-31", + "plan_date_range_type_id": self.date_range_type.id, + "control_level": "analytic_kpi", + } + ) + forward = self.env["budget.balance.forward"].create( + { + "name": "Test: Same Analytic Forward", + "from_budget_period_id": self.budget_period.id, + "to_budget_period_id": next_period.id, + } + ) + BalanceForwardLine.create( + { + "forward_id": forward.id, + "analytic_account_id": self.costcenter1.id, + "amount_balance": 500.0, + "amount_balance_forward": 500.0, + } + ) + # No bm_date_to -> the same analytic is reused as destination. + self.assertEqual( + forward.forward_line_ids.to_analytic_account_id, self.costcenter1 + ) + + next_plan_vals = { + "name": "Same Analytic Plan FY Next", + "budget_period_id": next_period.id, + "line_ids": [ + Command.create( + {"analytic_account_id": self.costcenter1.id, "amount": 100.0} + ) + ], + } + # Keep this core scenario on plan lines when budget_plan_detail is + # installed in the full CI suite. Its test helper otherwise creates + # default detail lines and replaces the explicit 100.0 with 2400.0. + if "is_confirm_plan" in self.BudgetPlan._fields: + next_plan_vals["is_confirm_plan"] = True + next_plan = self.BudgetPlan.create(next_plan_vals) + # Draft forward is not counted yet. + self.assertEqual(next_plan.line_ids.amount_forward_in, 0.0) + + forward.action_budget_balance_forward() + self.budget_control.invalidate_recordset() + next_plan.invalidate_recordset() + # Source: 2400 - 500 forwarded out = 1900. + self.assertEqual(self.budget_control.amount_forward_out, 500.0) + self.assertEqual(self.budget_control.amount_balance, 1900.0) + self.costcenter1.invalidate_recordset() + self.assertEqual(self.costcenter1.amount_forward_out, 500.0) + # Target: 500 forwarded in + 100 new = 600 available. + self.assertEqual(next_plan.line_ids.amount_forward_in, 500.0) + self.assertEqual(next_plan.line_ids.allocated_amount, 600.0) + + # --- Cancel a forward whose target is not yet in any workflow --- + cancellable = self.env["budget.balance.forward"].create( + { + "name": "Test: Cancellable Same Analytic Forward", + "from_budget_period_id": self.budget_period.id, + "to_budget_period_id": next_period.id, + } + ) + BalanceForwardLine.create( + { + "forward_id": cancellable.id, + "analytic_account_id": self.costcenterX.id, + "amount_balance": 100.0, + "amount_balance_forward": 100.0, + } + ) + self.budget_control2.invalidate_recordset() + cancellable.action_budget_balance_forward() + self.budget_control2.invalidate_recordset() + self.assertEqual(self.budget_control2.amount_balance, 2300.0) + cancellable.action_cancel() + self.budget_control2.invalidate_recordset() + # Cancel restores the source balance. + self.assertEqual(self.budget_control2.amount_balance, 2400.0) + + @freeze_time("2001-02-01") + def test_18c_balance_forward_different_analytic(self): + """Forward balance to a different analytic (method_type 'new') + tracks correctly.""" + BalanceForwardLine = self.env["budget.balance.forward.line"] + MonitorReport = self.env["budget.monitor.report"] + + # Source analytic ends before the next period -> method_type 'new'. + self.costcenter1.bm_date_to = f"{self.year}-12-31" + next_period = self.env["budget.period"].create( + { + "name": f"Budget for FY{self.year + 1}", + "template_id": self.template.id, + "bm_date_from": f"{self.year + 1}-01-01", + "bm_date_to": f"{self.year + 1}-12-31", + "plan_date_range_type_id": self.date_range_type.id, + "control_level": "analytic_kpi", + } + ) + # The destination analytic is resolved via next_year_analytic(). + next_analytic = self.costcenter1.next_year_analytic(auto_create=True) + self.assertNotEqual(next_analytic, self.costcenter1) + forward = self.env["budget.balance.forward"].create( + { + "name": "Test: Different Analytic Forward", + "from_budget_period_id": self.budget_period.id, + "to_budget_period_id": next_period.id, + } + ) + BalanceForwardLine.create( + { + "forward_id": forward.id, + "analytic_account_id": self.costcenter1.id, + "amount_balance": 300.0, + "amount_balance_forward": 300.0, + "method_type": "new", + } + ) + self.assertEqual(forward.forward_line_ids.to_analytic_account_id, next_analytic) + + next_plan = self.create_budget_plan( + name="Different Analytic Plan FY Next", + budget_period=next_period, + lines=[ + Command.create( + {"analytic_account_id": next_analytic.id, "amount": 200.0} + ) + ], + ) + forward.action_budget_balance_forward() + self.budget_control.invalidate_recordset() + next_plan.invalidate_recordset() + # Source: 2400 - 300 forwarded out = 2100. + self.assertEqual(self.budget_control.amount_forward_out, 300.0) + self.assertEqual(self.budget_control.amount_balance, 2100.0) + # Target: 300 forwarded in + 200 new = 500 available. + self.assertEqual(next_plan.line_ids.amount_forward_in, 300.0) + self.assertEqual(next_plan.line_ids.allocated_amount, 500.0) + + # Build the target budget control and verify its breakdown. + next_plan.action_create_update_budget_control() + target_control = self.BudgetControl.search( + [ + ("budget_period_id", "=", next_period.id), + ("analytic_account_id", "=", next_analytic.id), + ] + ) + self.env["budget.control.line"].create( + { + "budget_control_id": target_control.id, + "analytic_account_id": next_analytic.id, + "date_from": next_period.bm_date_from, + "date_to": next_period.bm_date_to, + "amount": 500.0, + } + ) + target_control.allocated_amount = 500.0 + target_control.invalidate_recordset() + self.assertEqual(target_control.amount_forward_in, 300.0) + self.assertEqual(target_control.amount_new_budget, 200.0) + self.assertEqual(target_control.amount_budget, 500.0) + + # Monitoring keeps the signed negative ledger amount; the target analytic + # only exposes its budget line (forward_in stays out to avoid duplicates). + report_fields = ["amount_type", "amount"] + groupby = ["amount_type"] + source_data = MonitorReport.read_group( + [ + ("analytic_account_id", "=", self.costcenter1.id), + ("budget_period_id", "=", self.budget_period.id), + ], + report_fields, + groupby, + lazy=False, + ) + source_amounts = {row["amount_type"]: row["amount"] for row in source_data} + self.assertEqual(source_amounts["12_forward_out"], -300.0) + target_data = MonitorReport.read_group( + [ + ("analytic_account_id", "=", next_analytic.id), + ("budget_period_id", "=", next_period.id), + ], + report_fields, + groupby, + lazy=False, + ) + target_amounts = {row["amount_type"]: row["amount"] for row in target_data} + self.assertNotIn("11_forward_in", target_amounts) + self.assertEqual(target_amounts["10_budget"], 500.0) + @freeze_time("2001-02-01") def test_19_unmatched_account_bypass_and_policy(self): """ diff --git a/budget_control/views/analytic_account_views.xml b/budget_control/views/analytic_account_views.xml index aeb89ecc..754e974a 100644 --- a/budget_control/views/analytic_account_views.xml +++ b/budget_control/views/analytic_account_views.xml @@ -37,7 +37,6 @@ widget="many2many_tags" optional="show" /> - @@ -66,16 +65,6 @@ - @@ -115,11 +104,6 @@ optional="show" /> - - + + + - + - -