import ast from datetime import date from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError from odoo.fields import Domain from odoo.tools import groupby from odoo.tools.date_utils import get_timedelta from odoo.tools.float_utils import float_is_zero, float_round class AccountTransferModel(models.Model): _name = 'account.transfer.model' _description = "Account Transfer Model" # DEFAULTS def _get_default_date_start(self): company = self.env.company return company.compute_fiscalyear_dates(date.today())['date_from'] if company else None def _get_default_journal(self): return self.env['account.journal'].search([ *self.env['account.journal']._check_company_domain(self.env.company), ('type', '=', 'general'), ], limit=1) name = fields.Char(required=True) active = fields.Boolean(default=True) journal_id = fields.Many2one('account.journal', required=True, string="Destination Journal", default=_get_default_journal) company_id = fields.Many2one('res.company', readonly=True, related='journal_id.company_id') date_start = fields.Date(string="Start Date", required=True, default=_get_default_date_start) date_stop = fields.Date(string="Stop Date", required=False) frequency = fields.Selection([('month', 'Monthly'), ('quarter', 'Quarterly'), ('year', 'Yearly')], required=True, default='month') account_ids = fields.Many2many('account.account', 'account_model_rel', string="Origin Accounts", domain="[('account_type', '!=', 'off_balance')]") line_ids = fields.One2many('account.transfer.model.line', 'transfer_model_id', string="Destination Accounts") move_ids = fields.One2many('account.move', 'transfer_model_id', string="Generated Moves") move_ids_count = fields.Integer(compute="_compute_move_ids_count") has_draft_moves = fields.Boolean(compute="_compute_has_draft_moves") total_percent = fields.Float(compute="_compute_total_percent", string="Total Percent", readonly=True) state = fields.Selection([('disabled', 'Disabled'), ('in_progress', 'In Progress')], default='disabled', required=True) conditions = fields.Char( string='Conditions', default='[]', help="A domain to filter the accounts from which the resulting entry will transfer amounts.", ) def copy(self, default=None): new_models = super().copy(default) for old_model, new_model in zip(self, new_models): new_model.account_ids += old_model.account_ids old_model.line_ids.copy({'transfer_model_id': new_model.id}) return new_models @api.ondelete(at_uninstall=False) def _unlink_with_check_moves(self): # Only unlink a transfer that has no posted/draft moves attached. for transfer in self: if transfer.move_ids_count > 0: posted_moves = any(move.state == 'posted' for move in transfer.move_ids) if posted_moves: raise UserError(_("You cannot delete a transfer model that has posted moves attached ('%s').", transfer.name)) draft_moves = any(move.state == 'draft' for move in transfer.move_ids) if draft_moves: raise UserError(_("You cannot delete a transfer model that has draft moves attached ('%s'). " "Please delete them before deleting this transfer.", transfer.name)) @api.depends('move_ids') def _compute_move_ids_count(self): """Compute the amount of move ids have been generated by this transfer model.""" for record in self: record.move_ids_count = len(record.move_ids) @api.depends('move_ids') def _compute_has_draft_moves(self): """Compute if there are any draft moves generated by this transfer model.""" for record in self: record.has_draft_moves = any(move.state == 'draft' for move in record.move_ids) @api.constrains('line_ids') def _check_line_ids_percent(self): """Check that the total percent is not bigger than 100.0.""" for record in self: if not 0 < record.total_percent <= 100.0: raise ValidationError(_('The total percentage (%s) should be less or equal to 100!', record.total_percent)) @api.depends('line_ids') def _compute_total_percent(self): """Compute the total percentage of all lines linked to this model.""" for record in self: total_percent = sum(record.line_ids.filtered(lambda line: line.account_id).mapped('percent')) record.total_percent = float_round(total_percent, precision_digits=6) def action_enable(self): """Put this move model in 'in progress' state.""" return self.write({'state': 'in_progress'}) def action_disable(self): """Put this move model in 'disabled' state.""" return self.write({'state': 'disabled'}) def action_archive(self): self.action_disable() return super().action_archive() @api.model def action_cron_auto_transfer(self): """Compute the transfer entries for the all active move models.""" self.search([('state', '=', 'in_progress')]).action_perform_auto_transfer() def action_perform_auto_transfer(self): """Compute the transfer entries for the current recordset of models.""" for record in self: # If no account to ventilate or no account to ventilate into : nothing to do if record.account_ids and record.line_ids: today = date.today() max_date = record.date_stop and min(today, record.date_stop) or today start_date = record._determine_start_date() next_move_date = record._get_next_move_date(start_date) # (Re)Generate moves in draft untill today # Journal entries will be recomputed everyday until posted. while next_move_date <= max_date: record._create_or_update_move_for_period(start_date, next_move_date) start_date = next_move_date + relativedelta(days=1) next_move_date = record._get_next_move_date(start_date) # (Re)Generate move for one more period if needed if not record.date_stop: record._create_or_update_move_for_period(start_date, next_move_date) elif today < record.date_stop: record._create_or_update_move_for_period(start_date, min(next_move_date, record.date_stop)) return False def _create_or_update_move_for_period(self, start_date, end_date): """ Create or update a transfer entry for a given period. This means (re)generate all the required moves and their associated move lines in order to execute the transfers. :param start_date: the start date of the targeted period :param end_date: the end date of the targeted period :return: the created (or updated) move """ self.ensure_one() current_move = self._get_move_for_period(end_date) if current_move and current_move.state != 'draft': return current_move line_values = self._get_transfer_move_lines_values(start_date, end_date) if line_values: if current_move is None: current_move = self.env['account.move'].create({ 'ref': '%s: %s --> %s' % (self.name, str(start_date), str(end_date)), 'date': end_date, 'journal_id': self.journal_id.id, 'transfer_model_id': self.id, }) line_ids_values = [(0, 0, value) for value in line_values] # unlink all old line ids current_move.line_ids.unlink() # recreate line ids current_move.write({'line_ids': line_ids_values}) return current_move def _get_move_for_period(self, end_date): """Get the generated move for a given period. :param end_date: the end date of the wished period, do not need the start date as the move will always be generated with end date of a period as date :return: a recordset containing the move found if any, else None """ self.ensure_one() # Move will always be generated with end_date of a period as date domain = Domain('date', '=', end_date) & Domain('transfer_model_id', '=', self.id) current_moves = self.env['account.move'].search(domain, limit=1, order="date desc") return current_moves[0] if current_moves else None def _determine_start_date(self): """Determine the transfer start date which is the most recent between: the global lock date, the hard lock date and the start date of the model.""" self.ensure_one() global_lock_date = self.company_id.fiscalyear_lock_date or fields.Date.to_date('1900-01-01') hard_lock_date = self.company_id.hard_lock_date or fields.Date.to_date('1900-01-01') return max(global_lock_date.replace(day=1), hard_lock_date.replace(day=1), self.date_start) def _get_next_move_date(self, date): """Compute the date of the next transfer entry with date and frequency.""" self.ensure_one() quantity = 3 if self.frequency == 'quarter' else 1 granularity = 'month' if self.frequency in ['month', 'quarter'] else 'year' delta = get_timedelta(quantity, granularity) return date + delta - relativedelta(days=1) def _get_transfer_move_lines_values(self, start_date, end_date): """ Get values to create the move to perform all needed transfers between accounts linked to current recordset for a given period :param start_date: the start date of the targeted period :param end_date: the end date of the targeted period :return: a list containing all the values needed to create the needed transfers :rtype: list """ self.ensure_one() transferred_amls = self.env['account.move.line'].search([ ('account_id', 'in', self.account_ids.ids), ('date', '>=', start_date), ('date', '<=', end_date), ('parent_state', '=', 'posted'), *ast.literal_eval(self.conditions), ]) if not transferred_amls: return [] values = [] currency_round = self.company_id.currency_id.round transferred_lines_by_account = dict(groupby(transferred_amls, lambda aml: aml.account_id.id)) transferred_lines_total_balance = sum(transferred_amls.mapped('balance')) amount_to_transfer = 0 for source_account in self.account_ids: transferred_lines_balance = sum(transferred_line.balance for transferred_line in transferred_lines_by_account.get(source_account.id, [])) if not float_is_zero(transferred_lines_balance, precision_digits=6): percentage = sum(self.line_ids.mapped('percent')) amount = currency_round(transferred_lines_balance * (percentage / 100.0)) values.append({ 'name': _("Transfer (%(percent)s%% from %(account)s)", percent=percentage, account=source_account.name), 'account_id': source_account.id, 'balance': -amount, 'date_maturity': end_date, }) amount_to_transfer += amount for transfer_line in self.line_ids: destination_account = transfer_line.account_id if destination_account == self.line_ids.mapped('account_id')[-1]: # The last destination line takes whatever remains to transfer instead of computing it in %, to avoid rounding issues. # The sum of all destination amounts must always match the amount removed from the source accounts, # regardless of whether the destination percentages add up to 100%. amount = amount_to_transfer else: amount = transferred_lines_total_balance * (transfer_line.percent / 100.0) amount = currency_round(amount) if not float_is_zero(amount, precision_digits=6): values.append({ 'name': _("Transfer (-%(percent)s%% to %(account)s)", percent=transfer_line.percent, account=destination_account.name), 'account_id': destination_account.id, 'balance': amount, 'date_maturity': end_date, }) amount_to_transfer -= amount return values