# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import date from dateutil.relativedelta import relativedelta from odoo import fields, models, api, _ from odoo.exceptions import UserError, ValidationError from odoo.osv import expression from odoo.tools import SQL from odoo.tools.float_utils import float_compare, float_is_zero class TransferModel(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) 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") total_percent = fields.Float(compute="_compute_total_percent", string="Total Percent", readonly=True) state = fields.Selection([('disabled', 'Disabled'), ('in_progress', 'Running')], default='disabled', required=True) def copy(self, default=None): default = default or {} res = super(TransferModel, self).copy(default) res.account_ids += self.account_ids for line in self.line_ids: line.copy({'transfer_model_id': res.id}) return res @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 an automatic transfer 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 an automatic transfer that has draft moves attached ('%s'). " "Please delete them before deleting this transfer.", transfer.name)) # COMPUTEDS / CONSTRAINS @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.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.constrains('line_ids') def _check_line_ids_filters(self): """ Check that the filters on the lines make sense """ for record in self: combinations = [] for line in record.line_ids: if line.partner_ids and line.analytic_account_ids: for p in line.partner_ids: for a in line.analytic_account_ids: combination = (p.id, a.id) if combination in combinations: raise ValidationError(_("The partner filter %s in combination with the analytic filter %s is duplicated", p.display_name, a.display_name)) combinations.append(combination) elif line.partner_ids: for p in line.partner_ids: combination = (p.id, None) if combination in combinations: raise ValidationError(_("The partner filter %s is duplicated", p.display_name)) combinations.append(combination) elif line.analytic_account_ids: for a in line.analytic_account_ids: combination = (None, a.id) if combination in combinations: raise ValidationError(_("The analytic filter %s is duplicated", a.display_name)) combinations.append(combination) @api.depends('line_ids') def _compute_total_percent(self): """ Compute the total percentage of all lines linked to this model. """ for record in self: non_filtered_lines = record.line_ids.filtered(lambda l: not l.partner_ids and not l.analytic_account_ids) if record.line_ids and not non_filtered_lines: # Lines are only composed of filtered ones thus percentage does not matter, make it 100 record.total_percent = 100.0 else: total_percent = sum(non_filtered_lines.mapped('percent')) if float_compare(total_percent, 100.0, precision_digits=6) == 0: total_percent = 100.0 record.total_percent = total_percent # ACTIONS def action_activate(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'}) @api.model def action_cron_auto_transfer(self): """ Perform the automatic transfer for the all active move models. """ self.search([('state', '=', 'in_progress')]).action_perform_auto_transfer() def action_perform_auto_transfer(self): """ Perform the automatic transfer 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 _get_move_lines_base_domain(self, start_date, end_date): """ Determine the domain to get all account move lines posted in a given period, for an account in origin accounts :param start_date: the start date of the period :param end_date: the end date of the period :return: the computed domain :rtype: list """ self.ensure_one() return [ ('account_id', 'in', self.account_ids.ids), ('date', '>=', start_date), ('date', '<=', end_date), ('parent_state', '=', 'posted') ] # PROTECTEDS def _create_or_update_move_for_period(self, start_date, end_date): """ Create or update a move for a given period. This means (re)generates all the needed moves 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) line_values = self._get_auto_transfer_move_line_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 = [ ('date', '=', end_date), ('state', '=', 'draft'), ('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 automatic transfer start date which is the last created move if any or the start date of the model """ self.ensure_one() # Get last generated move date if any (to know when to start) last_move_domain = [('transfer_model_id', '=', self.id), ('state', '=', 'posted'), ('company_id', '=', self.company_id.id)] move_ids = self.env['account.move'].search(last_move_domain, order='date desc', limit=1) return (move_ids[0].date + relativedelta(days=1)) if move_ids else self.date_start def _get_next_move_date(self, date): """ Compute the following date of automated transfer move, based on a date and the frequency """ self.ensure_one() if self.frequency == 'month': delta = relativedelta(months=1) elif self.frequency == 'quarter': delta = relativedelta(months=3) else: delta = relativedelta(years=1) return date + delta - relativedelta(days=1) def _get_auto_transfer_move_line_values(self, start_date, end_date): """ Get all the transfer move lines values for a given period :param start_date: the start date of the period :param end_date: the end date of the period :return: a list of dict representing the values of lines to create :rtype: list """ self.ensure_one() values = [] # Get the balance of all moves from all selected accounts, grouped by accounts filtered_lines = self.line_ids.filtered(lambda x: x.analytic_account_ids or x.partner_ids) if filtered_lines: values += filtered_lines._get_transfer_move_lines_values(start_date, end_date) non_filtered_lines = self.line_ids - filtered_lines if non_filtered_lines: values += self._get_non_filtered_auto_transfer_move_line_values(non_filtered_lines, start_date, end_date) return values def _get_non_filtered_auto_transfer_move_line_values(self, lines, start_date, end_date): """ Get all values to create move lines corresponding to the transfers needed by all lines without analytic account or partner for a given period. It contains the move lines concerning destination accounts and the ones concerning the origin accounts. This process all the origin accounts one after one. :param lines: the move model lines to handle :param start_date: the start date of the period :param end_date: the end date of the period :return: a list of dict representing the values to use to create the needed move lines :rtype: list """ self.ensure_one() domain = self._get_move_lines_base_domain(start_date, end_date) domain = expression.AND([domain, [('partner_id', 'not in', self.line_ids.partner_ids.ids), ]]) query = self.env['account.move.line']._search(domain) if self.line_ids.analytic_account_ids.ids: query.add_where( SQL( "(NOT %s && %s OR analytic_distribution IS NULL)", [str(account_id) for account_id in self.line_ids.analytic_account_ids.ids], self.env['account.move.line']._query_analytic_accounts(), ) ) query_string, query_param = query.select('SUM(balance) AS balance', 'account_id') query_string = f"{query_string} GROUP BY account_id ORDER BY account_id" self._cr.execute(query_string, query_param) # balance = debit - credit # --> balance > 0 means a debit so it should be credited on the source account # --> balance < 0 means a credit so it should be debited on the source account values_list = [] for total_balance_account in self._cr.dictfetchall(): initial_amount = abs(total_balance_account['balance']) source_account_is_debit = total_balance_account['balance'] >= 0 account_id = total_balance_account['account_id'] account = self.env['account.account'].browse(account_id) if not float_is_zero(initial_amount, precision_digits=9): move_lines_values, amount_left = self._get_non_analytic_transfer_values(account, lines, end_date, initial_amount, source_account_is_debit) # the line which credit/debit the source account substracted_amount = initial_amount - amount_left source_move_line = { 'name': _('Automatic Transfer (-%s%%)', self.total_percent), 'account_id': account_id, 'date_maturity': end_date, 'credit' if source_account_is_debit else 'debit': substracted_amount } values_list += move_lines_values values_list.append(source_move_line) return values_list def _get_non_analytic_transfer_values(self, account, lines, write_date, amount, is_debit): """ Get all values to create destination account move lines corresponding to the transfers needed by all lines without analytic account for a given account. :param account: the origin account to handle :param write_date: the write date of the move lines :param amount: the total amount to take care on the origin account :type amount: float :param is_debit: True if origin account has a debit balance, False if it's a credit :type is_debit: bool :return: a tuple containing the move lines values in a list and the amount left on the origin account after processing as a float :rtype: tuple """ # if total ventilated is 100% # then the last line should not compute in % but take the rest # else # it should compute in % (as the rest will stay on the source account) self.ensure_one() amount_left = amount take_the_rest = self.total_percent == 100.0 amount_of_lines = len(lines) values_list = [] for i, line in enumerate(lines): if take_the_rest and i == amount_of_lines - 1: line_amount = amount_left amount_left = 0 else: currency = self.journal_id.currency_id or self.company_id.currency_id line_amount = currency.round((line.percent / 100.0) * amount) amount_left -= line_amount move_line = line._get_destination_account_transfer_move_line_values(account, line_amount, is_debit, write_date) values_list.append(move_line) return values_list, amount_left class TransferModelLine(models.Model): _name = "account.transfer.model.line" _description = "Account Transfer Model Line" _order = "sequence, id" transfer_model_id = fields.Many2one('account.transfer.model', string="Transfer Model", required=True, ondelete='cascade') account_id = fields.Many2one('account.account', string="Destination Account", required=True, domain="[('account_type', '!=', 'off_balance')]") percent = fields.Float(string="Percent", required=True, default=100, help="Percentage of the sum of lines from the origin accounts will be transferred to the destination account") analytic_account_ids = fields.Many2many('account.analytic.account', string='Analytic Filter', help="Adds a condition to only transfer the sum of the lines from the origin accounts that match these analytic accounts to the destination account") partner_ids = fields.Many2many('res.partner', string='Partner Filter', help="Adds a condition to only transfer the sum of the lines from the origin accounts that match these partners to the destination account") percent_is_readonly = fields.Boolean(compute="_compute_percent_is_readonly") sequence = fields.Integer("Sequence") _sql_constraints = [ ( 'unique_account_by_transfer_model', 'UNIQUE(transfer_model_id, account_id)', 'Only one account occurrence by transfer model') ] @api.onchange('analytic_account_ids', 'partner_ids') def set_percent_if_analytic_account_ids(self): """ Set percent to 100 if at least analytic account id is set. """ for record in self: if record.analytic_account_ids or record.partner_ids: record.percent = 100 def _get_transfer_move_lines_values(self, start_date, end_date): """ Get values to create the move lines 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 """ transfer_values = [] # Avoid to transfer two times the same entry already_handled_move_line_ids = [] for transfer_model_line in self: domain = transfer_model_line._get_move_lines_domain(start_date, end_date, already_handled_move_line_ids) query = self.env['account.move.line']._search(domain) if transfer_model_line.analytic_account_ids: query.add_where( SQL( "%s && %s", [str(account_id) for account_id in transfer_model_line.analytic_account_ids.ids], self.env['account.move.line']._query_analytic_accounts(), ) ) query_string, query_param = query.select('array_agg("account_move_line".id) AS ids', 'SUM(balance) AS balance', 'account_id') query_string = f"{query_string} GROUP BY account_id ORDER BY account_id" self._cr.execute(query_string, query_param) total_balances_by_account = [expense for expense in self._cr.dictfetchall()] for total_balance_account in total_balances_by_account: already_handled_move_line_ids += total_balance_account['ids'] balance = total_balance_account['balance'] if not float_is_zero(balance, precision_digits=9): amount = abs(balance) source_account_is_debit = balance > 0 account_id = total_balance_account['account_id'] account = self.env['account.account'].browse(account_id) transfer_values += transfer_model_line._get_transfer_values(account, amount, source_account_is_debit, end_date) return transfer_values def _get_move_lines_domain(self, start_date, end_date, avoid_move_line_ids=None): """ Determine the domain to get all account move lines posted in a given period corresponding to self move model line. :param start_date: the start date of the targeted period :param end_date: the end date of the targeted period :param avoid_move_line_ids: the account.move.line ids that should be excluded from the domain :return: the computed domain :rtype: list """ self.ensure_one() move_lines_domain = self.transfer_model_id._get_move_lines_base_domain(start_date, end_date) if avoid_move_line_ids: move_lines_domain.append(('id', 'not in', avoid_move_line_ids)) if self.partner_ids: move_lines_domain.append(('partner_id', 'in', self.partner_ids.ids)) return move_lines_domain def _get_transfer_values(self, account, amount, is_debit, write_date): """ Get values to create the move lines to perform a transfer between self account and given account :param account: the account :param amount: the amount that is being transferred :type amount: float :param is_debit: True if the transferred amount is a debit, False if credit :type is_debit: bool :param write_date: the date to use for the move line writing :return: a list containing the values to create the needed move lines :rtype: list """ self.ensure_one() return [ self._get_destination_account_transfer_move_line_values(account, amount, is_debit, write_date), self._get_origin_account_transfer_move_line_values(account, amount, is_debit, write_date) ] def _get_origin_account_transfer_move_line_values(self, origin_account, amount, is_debit, write_date): """ Get values to create the move line in the origin account side for a given transfer of a given amount from origin account to a given destination account. :param origin_account: the origin account :param amount: the amount that is being transferred :type amount: float :param is_debit: True if the transferred amount is a debit, False if credit :type is_debit: bool :param write_date: the date to use for the move line writing :return: a dict containing the values to create the move line :rtype: dict """ anal_accounts = self.analytic_account_ids and ', '.join(self.analytic_account_ids.mapped('name')) partners = self.partner_ids and ', '.join(self.partner_ids.mapped('name')) if anal_accounts and partners: name = _("Automatic Transfer (entries with analytic account(s): %s and partner(s): %s)", anal_accounts, partners) elif anal_accounts: name = _("Automatic Transfer (entries with analytic account(s): %s)", anal_accounts) elif partners: name = _("Automatic Transfer (entries with partner(s): %s)", partners) else: name = _("Automatic Transfer (to account %s)", self.account_id.code) return { 'name': name, 'account_id': origin_account.id, 'date_maturity': write_date, 'credit' if is_debit else 'debit': amount } def _get_destination_account_transfer_move_line_values(self, origin_account, amount, is_debit, write_date): """ Get values to create the move line in the destination account side for a given transfer of a given amount from given origin account to destination account. :param origin_account: the origin account :param amount: the amount that is being transferred :type amount: float :param is_debit: True if the transferred amount is a debit, False if credit :type is_debit: bool :param write_date: the date to use for the move line writing :return: a dict containing the values to create the move line :rtype dict: """ anal_accounts = self.analytic_account_ids and ', '.join(self.analytic_account_ids.mapped('name')) partners = self.partner_ids and ', '.join(self.partner_ids.mapped('name')) if anal_accounts and partners: name = _("Automatic Transfer (from account %s with analytic account(s): %s and partner(s): %s)", origin_account.code, anal_accounts, partners) elif anal_accounts: name = _("Automatic Transfer (from account %s with analytic account(s): %s)", origin_account.code, anal_accounts) elif partners: name = _("Automatic Transfer (from account %s with partner(s): %s)", origin_account.code, partners) else: name = _("Automatic Transfer (%s%% from account %s)", self.percent, origin_account.code) return { 'name': name, 'account_id': self.account_id.id, 'date_maturity': write_date, 'debit' if is_debit else 'credit': amount } @api.depends('analytic_account_ids', 'partner_ids') def _compute_percent_is_readonly(self): for record in self: record.percent_is_readonly = record.analytic_account_ids or record.partner_ids