diff --git a/addons/om_account_budget/models/account_budget.py b/addons/om_account_budget/models/account_budget.py new file mode 100644 index 0000000..7206879 --- /dev/null +++ b/addons/om_account_budget/models/account_budget.py @@ -0,0 +1,270 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class AccountBudgetPost(models.Model): + _name = "account.budget.post" + _order = "name" + _description = "Budgetary Position" + + name = fields.Char('Name', required=True) + account_ids = fields.Many2many( + 'account.account', 'account_budget_rel', 'budget_id', + 'account_id', 'Accounts' + ) + company_id = fields.Many2one('res.company', 'Company', required=True, default=lambda self: self.env.company) + + def _check_account_ids(self, vals): + # Raise an error to prevent the account.budget.post to have not specified account_ids. + # This check is done on create because require=True doesn't work on Many2many fields. + if 'account_ids' in vals: + account_ids = self.new({'account_ids': vals['account_ids']}, origin=self).account_ids + else: + account_ids = self.account_ids + if not account_ids: + raise ValidationError(_('The budget must have at least one account.')) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self._check_account_ids(vals) + return super(AccountBudgetPost, self).create(vals_list) + + def write(self, vals): + self._check_account_ids(vals) + return super(AccountBudgetPost, self).write(vals) + + +class CrossoveredBudget(models.Model): + _name = "crossovered.budget" + _description = "Budget" + _inherit = ['mail.thread'] + + name = fields.Char('Budget Name', required=True) + user_id = fields.Many2one('res.users', 'Responsible', default=lambda self: self.env.user) + date_from = fields.Date('Start Date', required=True) + date_to = fields.Date('End Date', required=True) + state = fields.Selection([ + ('draft', 'Draft'), + ('cancel', 'Cancelled'), + ('confirm', 'Confirmed'), + ('validate', 'Validated'), + ('done', 'Done') + ], 'Status', default='draft', index=True, required=True, readonly=True, copy=False, tracking=True) + crossovered_budget_line = fields.One2many( + 'crossovered.budget.lines', 'crossovered_budget_id', + 'Budget Lines', copy=True + ) + company_id = fields.Many2one('res.company', 'Company', required=True, default=lambda self: self.env.company) + + def action_budget_confirm(self): + self.write({'state': 'confirm'}) + + def action_budget_draft(self): + self.write({'state': 'draft'}) + + def action_budget_validate(self): + self.write({'state': 'validate'}) + + def action_budget_cancel(self): + self.write({'state': 'cancel'}) + + def action_budget_done(self): + self.write({'state': 'done'}) + + +class CrossoveredBudgetLines(models.Model): + _name = "crossovered.budget.lines" + _description = "Budget Line" + + name = fields.Char(compute='_compute_line_name') + crossovered_budget_id = fields.Many2one('crossovered.budget', 'Budget', ondelete='cascade', index=True, required=True) + analytic_account_id = fields.Many2one('account.analytic.account', 'Analytic Account') + analytic_plan_id = fields.Many2one(related='analytic_account_id.plan_id') + general_budget_id = fields.Many2one('account.budget.post', 'Budgetary Position') + date_from = fields.Date('Start Date', required=True) + date_to = fields.Date('End Date', required=True) + paid_date = fields.Date('Paid Date') + currency_id = fields.Many2one('res.currency', related='company_id.currency_id', readonly=True) + planned_amount = fields.Monetary( + 'Planned Amount', required=True, + help="Amount you plan to earn/spend. Record a positive amount if it is a revenue and a negative amount if it is a cost.") + practical_amount = fields.Monetary( + compute='_compute_practical_amount', string='Practical Amount', help="Amount really earned/spent.") + theoritical_amount = fields.Monetary( + compute='_compute_theoritical_amount', string='Theoretical Amount', + help="Amount you are supposed to have earned/spent at this date.") + percentage = fields.Float( + compute='_compute_percentage', string='Achievement', + help="Comparison between practical and theoretical amount. This measure tells you if you are below or over budget.") + company_id = fields.Many2one(related='crossovered_budget_id.company_id', comodel_name='res.company', + string='Company', store=True, readonly=True) + is_above_budget = fields.Boolean(compute='_is_above_budget') + crossovered_budget_state = fields.Selection(related='crossovered_budget_id.state', string='Budget State', store=True, readonly=True) + + @api.model + def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): + # overrides the default read_group in order to compute the computed fields manually for the group + fields_list = {'practical_amount', 'theoritical_amount', 'percentage'} + fields = {field.split(':', 1)[0] if field.split(':', 1)[0] in fields_list else field for field in fields} + result = super(CrossoveredBudgetLines, self).read_group(domain, fields, groupby, offset=offset, limit=limit, + orderby=orderby, lazy=lazy) + if any(x in fields for x in fields_list): + for group_line in result: + + # initialise fields to compute to 0 if they are requested + if 'practical_amount' in fields: + group_line['practical_amount'] = 0 + if 'theoritical_amount' in fields: + group_line['theoritical_amount'] = 0 + if 'percentage' in fields: + group_line['percentage'] = 0 + group_line['practical_amount'] = 0 + group_line['theoritical_amount'] = 0 + + if group_line.get('__domain'): + all_budget_lines_that_compose_group = self.search(group_line['__domain']) + else: + all_budget_lines_that_compose_group = self.search([]) + for budget_line_of_group in all_budget_lines_that_compose_group: + if 'practical_amount' in fields or 'percentage' in fields: + group_line['practical_amount'] += budget_line_of_group.practical_amount + + if 'theoritical_amount' in fields or 'percentage' in fields: + group_line['theoritical_amount'] += budget_line_of_group.theoritical_amount + + if 'percentage' in fields: + if group_line['theoritical_amount']: + # use a weighted average + group_line['percentage'] = float( + (group_line['practical_amount'] or 0.0) / group_line['theoritical_amount']) * 100 + + return result + + def _is_above_budget(self): + for line in self: + if line.theoritical_amount >= 0: + line.is_above_budget = line.practical_amount > line.theoritical_amount + else: + line.is_above_budget = line.practical_amount < line.theoritical_amount + + def _compute_line_name(self): + #just in case someone opens the budget line in form view + for line in self: + computed_name = line.crossovered_budget_id.name + if line.general_budget_id: + computed_name += ' - ' + line.general_budget_id.name + if line.analytic_account_id: + computed_name += ' - ' + line.analytic_account_id.name + line.name = computed_name + + def _compute_practical_amount(self): + for line in self: + acc_ids = line.general_budget_id.account_ids.ids + date_to = line.date_to + date_from = line.date_from + if line.analytic_account_id.id: + analytic_line_obj = self.env['account.analytic.line'] + domain = [('account_id', '=', line.analytic_account_id.id), + ('date', '>=', date_from), + ('date', '<=', date_to), + ] + if acc_ids: + domain += [('general_account_id', 'in', acc_ids)] + + where_query = analytic_line_obj._where_calc(domain) + analytic_line_obj._apply_ir_rules(where_query, 'read') + from_string, from_params = where_query.from_clause + where_string, where_params = where_query.where_clause + from_clause, where_clause, where_clause_params = from_string, where_string, from_params + where_params + + select = "SELECT SUM(amount) from " + from_clause + " where " + where_clause + + else: + aml_obj = self.env['account.move.line'] + domain = [('account_id', 'in', + line.general_budget_id.account_ids.ids), + ('date', '>=', date_from), + ('date', '<=', date_to) + ] + where_query = aml_obj._where_calc(domain) + aml_obj._apply_ir_rules(where_query, 'read') + from_string, from_params = where_query.from_clause + where_string, where_params = where_query.where_clause + from_clause, where_clause, where_clause_params = from_string, where_string, from_params + where_params + + select = "SELECT sum(credit)-sum(debit) from " + from_clause + " where " + where_clause + + self.env.cr.execute(select, where_clause_params) + line.practical_amount = self.env.cr.fetchone()[0] or 0.0 + + def _compute_theoritical_amount(self): + # beware: 'today' variable is mocked in the python tests and thus, its implementation matter + today = fields.Date.today() + for line in self: + if line.paid_date: + if today <= line.paid_date: + theo_amt = 0.00 + else: + theo_amt = line.planned_amount + else: + line_timedelta = line.date_to - line.date_from + elapsed_timedelta = today - line.date_from + + if elapsed_timedelta.days < 0: + # If the budget line has not started yet, theoretical amount should be zero + theo_amt = 0.00 + elif line_timedelta.days > 0 and today < line.date_to: + # If today is between the budget line date_from and date_to + theo_amt = (elapsed_timedelta.total_seconds() / line_timedelta.total_seconds()) * line.planned_amount + else: + theo_amt = line.planned_amount + line.theoritical_amount = theo_amt + + def _compute_percentage(self): + for line in self: + if line.theoritical_amount != 0.00: + line.percentage = float((line.practical_amount or 0.0) / line.theoritical_amount) + else: + line.percentage = 0.00 + + @api.constrains('general_budget_id', 'analytic_account_id') + def _must_have_analytical_or_budgetary_or_both(self): + if not self.analytic_account_id and not self.general_budget_id: + raise ValidationError( + _("You have to enter at least a budgetary position or analytic account on a budget line.")) + + + def action_open_budget_entries(self): + if self.analytic_account_id: + # if there is an analytic account, then the analytic items are loaded + action = self.env['ir.actions.act_window']._for_xml_id('analytic.account_analytic_line_action_entries') + action['domain'] = [('account_id', '=', self.analytic_account_id.id), + ('date', '>=', self.date_from), + ('date', '<=', self.date_to) + ] + if self.general_budget_id: + action['domain'] += [('general_account_id', 'in', self.general_budget_id.account_ids.ids)] + else: + # otherwise the journal entries booked on the accounts of the budgetary postition are opened + action = self.env['ir.actions.act_window']._for_xml_id('account.action_account_moves_all_a') + action['domain'] = [('account_id', 'in', + self.general_budget_id.account_ids.ids), + ('date', '>=', self.date_from), + ('date', '<=', self.date_to) + ] + return action + + @api.constrains('date_from', 'date_to') + def _line_dates_between_budget_dates(self): + for rec in self: + budget_date_from = rec.crossovered_budget_id.date_from + budget_date_to = rec.crossovered_budget_id.date_to + if rec.date_from: + date_from = rec.date_from + if date_from < budget_date_from or date_from > budget_date_to: + raise ValidationError(_('"Start Date" of the budget line should be included in the Period of the budget')) + if rec.date_to: + date_to = rec.date_to + if date_to < budget_date_from or date_to > budget_date_to: + raise ValidationError(_('"End Date" of the budget line should be included in the Period of the budget'))