# Part of Fusion Accounting. See LICENSE file for full copyright and licensing details. from odoo import api, Command, fields, models, _ from odoo.exceptions import ValidationError from odoo.tools import date_utils, float_is_zero, float_round class FusionBudget(models.Model): """Represents a financial budget linked to accounting reports. A budget groups together individual budget line items, each targeting a specific account and month. Budgets are company-specific and appear as additional columns in accounting reports. """ _name = 'account.report.budget' _description = "Fusion Report Budget" _order = 'sequence, id' name = fields.Char( string="Budget Name", required=True, ) sequence = fields.Integer( string="Display Order", default=10, ) company_id = fields.Many2one( comodel_name='res.company', string="Company", required=True, default=lambda self: self.env.company, ) item_ids = fields.One2many( comodel_name='account.report.budget.item', inverse_name='budget_id', string="Budget Lines", ) # -------------------------------------------------- # CRUD # -------------------------------------------------- @api.model_create_multi def create(self, vals_list): """Override create to sanitize the budget name by stripping whitespace.""" for record_vals in vals_list: raw_name = record_vals.get('name') if raw_name: record_vals['name'] = raw_name.strip() return super().create(vals_list) # -------------------------------------------------- # Constraints # -------------------------------------------------- @api.constrains('name') def _check_budget_name_not_empty(self): """Ensure every budget record has a non-empty name.""" for record in self: if not record.name or not record.name.strip(): raise ValidationError( _("A budget must have a non-empty name.") ) # -------------------------------------------------- # Duplication helpers # -------------------------------------------------- def copy_data(self, default=None): """Append '(copy)' suffix to duplicated budget names.""" data_list = super().copy_data(default=default) result = [] for budget, vals in zip(self, data_list): vals['name'] = _("%s (copy)", budget.name) result.append(vals) return result def copy(self, default=None): """Duplicate budgets together with their line items.""" duplicated_budgets = super().copy(default) for source_budget, target_budget in zip(self, duplicated_budgets): for line in source_budget.item_ids: line.copy({ 'budget_id': target_budget.id, 'account_id': line.account_id.id, 'amount': line.amount, 'date': line.date, }) return duplicated_budgets # -------------------------------------------------- # Budget item management (called from report engine) # -------------------------------------------------- def _create_or_update_budget_items( self, value_to_set, account_id, rounding, date_from, date_to ): """Distribute a target amount across monthly budget items. When the user edits a budget cell in the report view, this method calculates the difference between the desired total and the existing total for the given account/date range, then distributes that delta evenly across the months in the range. Existing items within the range are updated in place; new items are created for months that don't have one yet. Args: value_to_set: The desired total amount for the date range. account_id: The ``account.account`` record id. rounding: Number of decimal digits for monetary precision. date_from: Start date (inclusive) of the budget period. date_to: End date (inclusive) of the budget period. """ self.ensure_one() period_start = fields.Date.to_date(date_from) period_end = fields.Date.to_date(date_to) BudgetItem = self.env['account.report.budget.item'] # Fetch all items that already cover (part of) the requested range matching_items = BudgetItem.search_fetch( [ ('budget_id', '=', self.id), ('account_id', '=', account_id), ('date', '>=', period_start), ('date', '<=', period_end), ], ['id', 'amount'], ) current_total = sum(matching_items.mapped('amount')) # Calculate the remaining amount to distribute remaining_delta = value_to_set - current_total if float_is_zero(remaining_delta, precision_digits=rounding): return # Build a list of first-of-month dates spanning the period month_starts = [ date_utils.start_of(d, 'month') for d in date_utils.date_range(period_start, period_end) ] month_count = len(month_starts) # Spread the delta equally across months (rounding down), # then assign any leftover cents to the final month. per_month = float_round( remaining_delta / month_count, precision_digits=rounding, rounding_method='DOWN', ) monthly_portions = [per_month] * month_count distributed_sum = float_round(sum(monthly_portions), precision_digits=rounding) monthly_portions[-1] += float_round( remaining_delta - distributed_sum, precision_digits=rounding, ) # Pair existing items with months and amounts; create or update as needed write_commands = [] idx = 0 for month_date, portion in zip(month_starts, monthly_portions): if idx < len(matching_items): # Update an existing item existing = matching_items[idx] write_commands.append( Command.update(existing.id, { 'amount': existing.amount + portion, }) ) else: # No existing item for this slot – create a new one write_commands.append( Command.create({ 'account_id': account_id, 'amount': portion, 'date': month_date, }) ) idx += 1 if write_commands: self.item_ids = write_commands # Ensure the ORM flushes new records to the database so # subsequent queries within the same request see them. BudgetItem.flush_model() class FusionBudgetItem(models.Model): """A single monthly budget entry for one account within a budget. Each item records a monetary amount allocated to a specific ``account.account`` for a particular month. The ``date`` field stores the first day of the relevant month. """ _name = 'account.report.budget.item' _description = "Fusion Report Budget Line" budget_id = fields.Many2one( comodel_name='account.report.budget', string="Parent Budget", required=True, ondelete='cascade', ) account_id = fields.Many2one( comodel_name='account.account', string="Account", required=True, ) date = fields.Date( string="Month", required=True, ) amount = fields.Float( string="Budgeted Amount", default=0.0, )