221 lines
7.7 KiB
Python
221 lines
7.7 KiB
Python
# 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,
|
||
)
|