252 lines
13 KiB
Python
252 lines
13 KiB
Python
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
|