Files
enterprise-addons/account_accountant/wizard/account_reconcile_wizard.py
T

789 lines
39 KiB
Python

from collections import defaultdict
from datetime import timedelta
from odoo import api, Command, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import groupby, SQL
from odoo.tools.misc import formatLang
class AccountReconcileWizard(models.TransientModel):
""" This wizard is used to reconcile selected account.move.line. """
_name = 'account.reconcile.wizard'
_description = 'Account reconciliation wizard'
_check_company_auto = True
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if 'move_line_ids' not in fields:
return res
if self.env.context.get('active_model') != 'account.move.line' or not self.env.context.get('active_ids'):
raise UserError(_('This can only be used on journal items'))
move_line_ids = self.env['account.move.line'].browse(self.env.context['active_ids'])
accounts = move_line_ids.account_id
if len(accounts) > 2:
raise UserError(_(
'You can only reconcile entries with up to two different accounts: %s',
', '.join(accounts.mapped('display_name')),
))
shadowed_aml_values = None
if len(accounts) == 2:
shadowed_aml_values = {
aml: {'account_id': move_line_ids[0].account_id}
for aml in move_line_ids.filtered(lambda line: line.account_id != move_line_ids[0].account_id)
}
move_line_ids._check_amls_exigibility_for_reconciliation(shadowed_aml_values=shadowed_aml_values)
res['move_line_ids'] = [Command.set(move_line_ids.ids)]
return res
company_id = fields.Many2one(comodel_name='res.company', required=True, readonly=True, compute='_compute_company_id')
move_line_ids = fields.Many2many(
comodel_name='account.move.line',
string='Move lines to reconcile',
required=True)
reco_account_id = fields.Many2one(
comodel_name='account.account',
string='Reconcile Account',
compute='_compute_reco_wizard_data')
amount = fields.Monetary(
string='Amount in company currency',
currency_field='company_currency_id',
compute='_compute_reco_wizard_data')
company_currency_id = fields.Many2one(comodel_name='res.currency', string='Company currency', related='company_id.currency_id')
amount_currency = fields.Monetary(
string='Amount',
currency_field='reco_currency_id',
compute='_compute_reco_wizard_data')
reco_currency_id = fields.Many2one(
comodel_name='res.currency',
string='Currency to use for reconciliation',
compute='_compute_reco_wizard_data')
edit_mode_amount = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_edit_mode_amount',
)
edit_mode_amount_currency = fields.Monetary(
string='Edit mode amount',
currency_field='edit_mode_reco_currency_id',
compute='_compute_edit_mode_amount_currency',
store=True,
readonly=False,
)
edit_mode_reco_currency_id = fields.Many2one(
comodel_name='res.currency',
compute='_compute_edit_mode_reco_currency',
)
edit_mode = fields.Boolean(
compute='_compute_edit_mode',
)
single_currency_mode = fields.Boolean(compute='_compute_single_currency_mode')
allow_partials = fields.Boolean(string="Allow partials", compute='_compute_allow_partials', store=True, readonly=False)
force_partials = fields.Boolean(compute='_compute_reco_wizard_data')
display_allow_partials = fields.Boolean(compute='_compute_display_allow_partials')
date = fields.Date(string='Date', compute='_compute_date', store=True, readonly=False)
journal_id = fields.Many2one(
comodel_name='account.journal',
string='Journal',
check_company=True,
domain="[('type', '=', 'general')]",
compute='_compute_journal_id',
store=True,
readonly=False,
required=True,
precompute=True)
account_id = fields.Many2one(
comodel_name='account.account',
string='Account',
check_company=True,
domain="[('account_type', '!=', 'off_balance')]")
is_rec_pay_account = fields.Boolean(compute='_compute_is_rec_pay_account')
to_partner_id = fields.Many2one(
comodel_name='res.partner',
string='Partner',
check_company=True,
compute='_compute_to_partner_id',
store=True,
readonly=False,
)
label = fields.Char(string='Label', default='Write-Off')
tax_id = fields.Many2one(
comodel_name='account.tax',
string='Tax',
default=False,
check_company=True)
to_check = fields.Boolean(
string='To Check',
default=False,
help='Check if you are not certain of all the information of the counterpart.')
is_write_off_required = fields.Boolean(
string='Is a write-off move required to reconcile',
compute='_compute_is_write_off_required')
is_transfer_required = fields.Boolean(
string='Is an account transfer required',
compute='_compute_reco_wizard_data')
transfer_warning_message = fields.Char(
string='Is an account transfer required to reconcile',
compute='_compute_reco_wizard_data')
transfer_from_account_id = fields.Many2one(
comodel_name='account.account',
string='Account Transfer From',
compute='_compute_reco_wizard_data')
lock_date_violated_warning_message = fields.Char(
string='Is the date violating the lock date of moves',
compute='_compute_lock_date_violated_warning_message')
reco_model_id = fields.Many2one(
comodel_name='account.reconcile.model',
string='Reconciliation model',
store=False,
check_company=True)
reco_model_autocomplete_ids = fields.Many2many(
comodel_name='account.reconcile.model',
string='All reconciliation models',
compute='_compute_reco_model_autocomplete_ids')
# ==== Compute methods ====
@api.depends('move_line_ids.company_id')
def _compute_company_id(self):
for wizard in self:
wizard.company_id = wizard.move_line_ids[0].company_id
@api.depends('move_line_ids')
def _compute_single_currency_mode(self):
for wizard in self:
wizard.single_currency_mode = len(wizard.move_line_ids.currency_id - wizard.company_currency_id) <= 1
@api.depends('force_partials')
def _compute_allow_partials(self):
for wizard in self:
wizard.allow_partials = wizard.display_allow_partials and wizard.force_partials
@api.depends('move_line_ids')
def _compute_display_allow_partials(self):
""" We only display the allow partial checkbox if there is both credit and debit lines involved. """
for wizard in self:
wizard.display_allow_partials = has_debit_line = has_credit_line = False
for aml in wizard.move_line_ids:
if aml.balance > 0.0 or aml.amount_currency > 0.0:
has_debit_line = True
elif aml.balance < 0.0 or aml.amount_currency < 0.0:
has_credit_line = True
if has_debit_line and has_credit_line:
wizard.display_allow_partials = True
break
@api.depends('move_line_ids', 'journal_id', 'tax_id')
def _compute_date(self):
for wizard in self:
highest_date = max(aml.date for aml in wizard.move_line_ids)
temp_move = self.env['account.move'].new({'journal_id': wizard.journal_id.id})
wizard.date = temp_move._get_accounting_date(highest_date, bool(wizard.tax_id))
@api.depends('company_id')
def _compute_journal_id(self):
for wizard in self:
wizard.journal_id = self.env['account.journal'].search([
*self.env['account.journal']._check_company_domain(wizard.company_id),
('type', '=', 'general')
], limit=1)
@api.depends('account_id')
def _compute_is_rec_pay_account(self):
for wizard in self:
wizard.is_rec_pay_account = wizard.account_id.account_type in ('asset_receivable', 'liability_payable')
@api.depends('is_rec_pay_account')
def _compute_to_partner_id(self):
for wizard in self:
if wizard.is_rec_pay_account:
partners = wizard.move_line_ids.partner_id
wizard.to_partner_id = partners if len(partners) == 1 else None
else:
wizard.to_partner_id = None
@api.depends('amount', 'amount_currency')
def _compute_is_write_off_required(self):
""" We need a write-off if the balance is not 0 and if we don't allow partial reconciliation."""
for wizard in self:
wizard.is_write_off_required = not wizard.company_currency_id.is_zero(wizard.amount) \
or (wizard.reco_currency_id and not wizard.reco_currency_id.is_zero(wizard.amount_currency))
@api.depends('move_line_ids')
def _compute_reco_wizard_data(self):
""" Compute various data needed for the reco wizard.
1. The currency to use for the reconciliation:
- if only one foreign currency is present in move lines we use it, unless the reco_account is not
payable nor receivable,
- if no foreign currency or more than 1 are used we use the company's default currency.
2. The account the reconciliation will happen on.
3. Transfer data.
4. Write-off amounts.
"""
def get_transfer_data(move_lines):
amounts_per_account = defaultdict(float)
for line in move_lines:
amounts_per_account[line.account_id] += line.amount_residual
if abs(amounts_per_account[accounts[0]]) < abs(amounts_per_account[accounts[1]]):
transfer_from_account, transfer_to_account = accounts[0], accounts[1]
else:
transfer_from_account, transfer_to_account = accounts[1], accounts[0]
amls_to_transfer = amls.filtered(lambda aml: aml.account_id == transfer_from_account)
transfer_foreign_curr = amls.currency_id - amls.company_currency_id
if len(transfer_foreign_curr) == 1:
transfer_currency = transfer_foreign_curr
transfer_amount_currency = sum(aml.amount_currency for aml in amls_to_transfer)
else:
transfer_currency = amls.company_currency_id
transfer_amount_currency = sum(aml.balance for aml in amls_to_transfer)
if transfer_amount_currency == 0.0 and transfer_currency != amls.company_currency_id:
# handle the transfer of exchange diff
transfer_currency = amls.company_currency_id
transfer_amount_currency = sum(aml.balance for aml in amls_to_transfer)
amount_formatted = formatLang(self.env, abs(transfer_amount_currency), currency_obj=transfer_currency)
transfer_warning_message = _(
'An entry will transfer %(amount)s from %(from_account)s to %(to_account)s.',
amount=amount_formatted,
from_account=transfer_from_account.display_name if transfer_amount_currency < 0 else transfer_to_account.display_name,
to_account=transfer_to_account.display_name if transfer_amount_currency < 0 else transfer_from_account.display_name,
)
return {
'transfer_from_account_id': transfer_from_account,
'reco_account_id': transfer_to_account,
'transfer_warning_message': transfer_warning_message,
}
def get_reco_currency(amls, aml_values_map):
company_currency = amls.company_currency_id
foreign_currencies = amls.currency_id - company_currency
if len(foreign_currencies) == 0:
return company_currency
elif len(foreign_currencies) == 1:
return foreign_currencies
else:
lines_with_residuals = self.env['account.move.line']
for residual, residual_values in aml_values_map.items():
if residual_values['amount_residual'] or residual_values['amount_residual_currency']:
lines_with_residuals += residual
if lines_with_residuals and len(lines_with_residuals.currency_id - company_currency) > 1:
# there is more than one residual and more than one currency in them
return False
return (lines_with_residuals.currency_id - company_currency) or company_currency
for wizard in self:
amls = wizard.move_line_ids._origin
accounts = amls.account_id # there is only 1 or 2 possible accounts
wizard.reco_currency_id = False
wizard.amount_currency = wizard.amount = 0.0
wizard.force_partials = True
wizard.transfer_from_account_id = wizard.transfer_warning_message = False
wizard.is_transfer_required = len(accounts) == 2
if wizard.is_transfer_required:
wizard.update(get_transfer_data(amls))
else:
wizard.reco_account_id = accounts
# Compute the residual amounts for each account.
shadowed_aml_values = {
aml: {'account_id': wizard.reco_account_id}
for aml in amls
}
# Batch the amls all together to know what should be reconciled and when.
plan_list, all_amls = amls._optimize_reconciliation_plan([amls], shadowed_aml_values=shadowed_aml_values)
# Prefetch data
all_amls.move_id
all_amls.matched_debit_ids
all_amls.matched_credit_ids
# All residual amounts are collected and updated until the creation of partials in batch.
# This is done that way to minimize the orm time for fields invalidation/mark as recompute and
# re-computation.
aml_values_map = {
aml: {
'aml': aml,
'amount_residual': aml.amount_residual,
'amount_residual_currency': aml.amount_residual_currency,
}
for aml in all_amls
}
plan = plan_list[0]
# residuals are subtracted from aml_values_map
amls._prepare_reconciliation_plan(plan, aml_values_map, shadowed_aml_values=shadowed_aml_values)
reco_currency = get_reco_currency(amls, aml_values_map)
if not reco_currency:
continue # stop the computation, no possible write-off => force partials
residual_amounts = {
aml: aml._prepare_move_line_residual_amounts(aml_values, reco_currency, shadowed_aml_values=shadowed_aml_values)
for aml, aml_values in aml_values_map.items()
}
if all(reco_currency in residual_values for residual_values in residual_amounts.values() if residual_values):
wizard.reco_currency_id = reco_currency
elif all(amls.company_currency_id in residual_values for residual_values in residual_amounts.values() if residual_values):
wizard.reco_currency_id = amls.company_currency_id
reco_currency = wizard.reco_currency_id
else:
continue # stop the computation, no possible write-off => force partials
# Compute write-off amounts
most_recent_line = max(amls, key=lambda aml: aml.date)
if not most_recent_line.amount_currency:
rate = rate_lower_bound = rate_upper_bound = 0.0
elif most_recent_line.currency_id == reco_currency:
rate = abs(most_recent_line.balance / most_recent_line.amount_currency)
# By estimating the rate from the most recent line, we are exposing ourselves to an estimation error because
# balance is amount_currency * rate, rounded to the nearest precision of the company currency. We now compute
# the lower/upper bounds of the estimated rate in order to determine which other AMLs share the same rate.
# For AMLs that share the same rate, we will use the existing amount_residual instead of recomputing it
# based on the amount_residual_currency and the rate.
rate_tolerance = amls.company_currency_id.rounding / 2 / abs(most_recent_line.amount_currency)
rate_lower_bound = rate - rate_tolerance
rate_upper_bound = rate + rate_tolerance
else:
rate = self.env['res.currency']._get_conversion_rate(reco_currency, amls.company_currency_id, amls.company_id, most_recent_line.date)
rate_lower_bound = rate_upper_bound = rate
# If an AML's rate is close enough to the reconciliation rate that it could be the same,
# use the `amount_residual` instead of computing `amount_residual_currency / rate` and rounding.
# We do this for the case where a single rate is used for all reconciled AMLs so we want to avoid
# creating an exchange diff because that would be weird.
amls_where_amounts_at_correct_rate = {
aml
for aml, residual_values in residual_amounts.items()
if (
aml.currency_id == reco_currency
and abs(aml.balance) >= aml.company_currency_id.round(abs(aml.amount_currency) * rate_lower_bound)
and abs(aml.balance) <= aml.company_currency_id.round(abs(aml.amount_currency) * rate_upper_bound)
)
}
wizard.amount_currency = sum(
residual_values[wizard.reco_currency_id]['residual']
for residual_values in residual_amounts.values()
if residual_values
)
amount_raw = sum(
(
residual_values[amls.company_currency_id]['residual']
if aml in amls_where_amounts_at_correct_rate and amls.company_currency_id in residual_values else
residual_values[wizard.reco_currency_id]['residual'] * rate
)
for aml, residual_values in residual_amounts.items()
if residual_values
)
wizard.amount = amls.company_currency_id.round(amount_raw)
wizard.force_partials = False
@api.depends('move_line_ids')
def _compute_edit_mode_amount_currency(self):
for wizard in self:
if wizard.edit_mode:
wizard.edit_mode_amount_currency = wizard.amount_currency
else:
wizard.edit_mode_amount_currency = 0.0
@api.depends('edit_mode_amount_currency')
def _compute_edit_mode_amount(self):
for wizard in self:
if wizard.edit_mode:
single_line = wizard.move_line_ids
rate = abs(single_line.amount_currency / single_line.balance) if single_line.balance else 0.0
wizard.edit_mode_amount = single_line.company_currency_id.round(wizard.edit_mode_amount_currency / rate) if rate else 0.0
else:
wizard.edit_mode_amount = 0.0
@api.depends('move_line_ids')
def _compute_edit_mode_reco_currency(self):
for wizard in self:
if wizard.edit_mode:
wizard.edit_mode_reco_currency_id = wizard.move_line_ids.currency_id
else:
wizard.edit_mode_reco_currency_id = False
@api.depends('move_line_ids')
def _compute_edit_mode(self):
for wizard in self:
wizard.edit_mode = len(wizard.move_line_ids) == 1
@api.depends('move_line_ids.move_id', 'date')
def _compute_lock_date_violated_warning_message(self):
for wizard in self:
date_after_lock = wizard._get_date_after_lock_date()
lock_date_violated_warning_message = None
if date_after_lock:
lock_date_violated_warning_message = _(
'The date you set violates the lock date of one of your entry. It will be overriden by the following date : %(replacement_date)s',
replacement_date=date_after_lock,
)
wizard.lock_date_violated_warning_message = lock_date_violated_warning_message
@api.depends('company_id', 'move_line_ids.partner_id', 'amount')
def _compute_reco_model_autocomplete_ids(self):
""" Computes available reconcile models, we only take manually created models that
have one (and only one) line, and have partners, amount, or journal that match the
ones of the entries.
"""
for wizard in self:
domain = [
('company_id', '=', wizard.company_id.id),
'|',
('match_partner_ids', '=', False),
('match_partner_ids', 'any', [('id', 'in', wizard.move_line_ids.partner_id.ids)]),
'|',
('match_amount', '=', False),
'|',
'&',
('match_amount', '=', 'lower'),
('match_amount_max', '>', wizard.amount),
'|',
'&',
('match_amount', '=', 'greater'),
('match_amount_min', '<', wizard.amount),
'&',
('match_amount', '=', 'between'),
'&',
('match_amount_min', '<', wizard.amount),
('match_amount_max', '>', wizard.amount),
'|',
('match_journal_ids', '=', False),
('match_journal_ids', 'in', wizard.move_line_ids.journal_id.ids),
('created_automatically', '=', False),
]
query = self.env['account.reconcile.model']._search(domain, bypass_access=True)
reco_model_ids = [r[0] for r in self.env.execute_query(SQL("""
SELECT account_reconcile_model.id
FROM %s
JOIN account_reconcile_model_line line ON line.model_id = account_reconcile_model.id
WHERE %s
GROUP BY account_reconcile_model.id
HAVING COUNT(account_reconcile_model.id) = 1
""", query.from_clause, query.where_clause or SQL("TRUE")))]
wizard.reco_model_autocomplete_ids = self.env['account.reconcile.model'].browse(reco_model_ids)
# ==== Onchange methods ====
@api.onchange('reco_model_id')
def _onchange_reco_model_id(self):
""" We prefill the write-off data with the reconcile model selected. """
if self.reco_model_id:
self.label = self.reco_model_id.line_ids.label
self.tax_id = self.reco_model_id.line_ids.tax_ids[0] if self.reco_model_id.line_ids[0].tax_ids else None
self.account_id = self.reco_model_id.line_ids.account_id
# ==== Python constrains ====
@api.constrains('edit_mode_amount_currency')
def _check_min_max_edit_mode_amount_currency(self):
for wizard in self:
if wizard.edit_mode:
if wizard.edit_mode_amount_currency == 0.0:
raise UserError(_('The amount of the write-off of a single line cannot be 0.'))
is_debit_line = wizard.move_line_ids.balance > 0.0 or wizard.move_line_ids.amount_currency > 0.0
if is_debit_line and wizard.edit_mode_amount_currency < 0.0:
raise UserError(_('The amount of the write-off of a single debit line should be strictly positive.'))
elif not is_debit_line and wizard.edit_mode_amount_currency > 0.0:
raise UserError(_('The amount of the write-off of a single credit line should be strictly negative.'))
# ==== Actions methods ====
def _action_open_wizard(self):
self.ensure_one()
return {
'name': _('Write-Off Entry'),
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'account.reconcile.wizard',
'target': 'new',
}
# ==== Business methods ====
def _get_date_after_lock_date(self):
self.ensure_one()
lock_dates = self.company_id._get_violated_lock_dates(self.date, bool(self.tax_id), self.journal_id)
if lock_dates:
return lock_dates[-1][0] + timedelta(days=1)
def _compute_write_off_taxes_data(self, partner):
""" Computes the data needed to fill the write-off lines related to taxes.
:return: a dict of the form {
'base_amount': 100.0,
'base_amount_currency': 200.0,
'tax_lines_data': [{
'tax_amount': 21.0,
'tax_amount_currency': 42.0,
'tax_tag_ids': [tax_tags],
'tax_account_id': id_of_account,
} * nr of repartition lines of the self.tax_id ],
}
"""
AccountTax = self.env['account.tax']
amount_currency = self.edit_mode_amount_currency or self.amount_currency
amount = self.edit_mode_amount or self.amount
rate = abs(amount_currency / amount)
tax_type = self.tax_id.type_tax_use if self.tax_id else None
is_refund = (tax_type == 'sale' and amount_currency > 0.0) or (tax_type == 'purchase' and amount_currency < 0.0)
base_line = AccountTax._prepare_base_line_for_taxes_computation(
self,
partner_id=partner,
currency_id=self.reco_currency_id,
tax_ids=self.tax_id,
price_unit=amount_currency,
quantity=1.0,
account_id=self.account_id,
is_refund=is_refund,
rate=rate,
special_mode='total_included',
)
base_lines = [base_line]
AccountTax._add_tax_details_in_base_lines(base_lines, self.company_id)
AccountTax._round_base_lines_tax_details(base_lines, self.company_id)
AccountTax._add_accounting_data_in_base_lines_tax_details(base_lines, self.company_id, include_caba_tags=True)
tax_results = AccountTax._prepare_tax_lines(base_lines, self.company_id)
_base_line, base_to_update = tax_results['base_lines_to_update'][0] # we can only have one baseline
tax_lines_data = []
for tax_line_vals in tax_results['tax_lines_to_add']:
tax_lines_data.append({
'tax_amount': tax_line_vals['balance'],
'tax_amount_currency': tax_line_vals['amount_currency'],
'tax_tag_ids': tax_line_vals['tax_tag_ids'],
'tax_account_id': tax_line_vals['account_id'],
})
base_amount_currency = base_to_update['amount_currency']
base_amount = amount - sum(entry['tax_amount'] for entry in tax_lines_data)
return {
'base_amount': base_amount,
'base_amount_currency': base_amount_currency,
'base_tax_tag_ids': base_to_update['tax_tag_ids'],
'tax_lines_data': tax_lines_data,
}
def _create_write_off_lines(self, partner=None):
if not partner:
partner = self.env['res.partner']
to_partner = self.to_partner_id if self.is_rec_pay_account else partner
tax_data = self._compute_write_off_taxes_data(to_partner) if self.tax_id else None
amount_currency = self.edit_mode_amount_currency or self.amount_currency
amount = self.edit_mode_amount or self.amount
line_ids_commands = [
Command.create({
'name': self.label or _('Write-Off'),
'account_id': self.reco_account_id.id,
'partner_id': partner.id,
'currency_id': self.reco_currency_id.id,
'amount_currency': -amount_currency,
'balance': -amount,
}),
Command.create({
'name': self.label,
'account_id': self.account_id.id,
'partner_id': to_partner.id,
'currency_id': self.reco_currency_id.id,
'tax_ids': self.tax_id.ids,
'tax_tag_ids': None if not tax_data else tax_data['base_tax_tag_ids'],
'amount_currency': amount_currency if not tax_data else tax_data['base_amount_currency'],
'balance': amount if not tax_data else tax_data['base_amount'],
}),
]
# Add taxes lines to the write-off lines, one per repartition line
if tax_data:
for tax_datum in tax_data['tax_lines_data']:
line_ids_commands.append(Command.create({
'name': self.tax_id.name,
'account_id': tax_datum['tax_account_id'],
'partner_id': to_partner.id,
'currency_id': self.reco_currency_id.id,
'tax_tag_ids': tax_datum['tax_tag_ids'],
'amount_currency': tax_datum['tax_amount_currency'],
'balance': tax_datum['tax_amount'],
}))
return line_ids_commands
def create_write_off(self):
""" Create write-off move lines with the data provided in the wizard. """
self.ensure_one()
partners = self.move_line_ids.partner_id
partner = partners if len(partners) == 1 else None
write_off_vals = {
'journal_id': self.journal_id.id,
'company_id': self.company_id.id,
'date': self._get_date_after_lock_date() or self.date,
'move_type': 'entry',
'checked': not self.to_check,
'line_ids': self._create_write_off_lines(partner=partner)
}
write_off_move = self.env['account.move'].with_context(
skip_invoice_sync=True,
skip_invoice_line_sync=True,
).create(write_off_vals)
write_off_move.action_post()
return write_off_move
def create_transfer(self):
""" Create transfer move.
We transfer lines squashed by partner and by currency to keep the partner ledger correct.
By default, the source line and the destination line of each transfer are linked to the same partner.
However, it can happen that the moves that are being reconciled together come from different partners
(e.g. reconciling an invoice of Partner X with a bill of Parnter Y).
In that case, one of the transfer lines should be set to the other partner.
Otherwise, the partner ledger will be wrong for both users.
"""
self.ensure_one()
line_ids = []
lines_to_transfer = self.move_line_ids.filtered(lambda line: line.account_id == self.transfer_from_account_id)
# The lines that are not transferred will be used to determine the partners of the destination lines
other_lines = self.move_line_ids.filtered(lambda line: line.account_id == self.reco_account_id)
# Default dict for the destination lines. It contains the inverse of the source lines
# {(partner, currency, sign): {'balance': float, 'amount_currency': float}}
default_destination_data = dict()
# Dict generated from the lines linked to the reconcile model.
# It is splitted by partner and represents the possible partners and the amount max that can be used
# for the destination lines.
# {(currency, sign): {(partner): {'balance': float, 'amount_currency': float}}}
amounts_by_partner = defaultdict(lambda: defaultdict(lambda: defaultdict(float)))
# Final dict containing the data from which the destination lines will be created
# {(partner, currency, sign): {'balance': float, 'amount_currency': float}}
destination_data = defaultdict(lambda: defaultdict(float))
for (partner, currency), lines_to_transfer_partner in groupby(lines_to_transfer, lambda l: (l.partner_id, l.currency_id)):
amount = sum(line.amount_residual for line in lines_to_transfer_partner)
amount_currency = sum(line.amount_residual_currency for line in lines_to_transfer_partner)
line_ids += [
Command.create({
'name': _('Transfer to %s', self.reco_account_id.display_name),
'account_id': self.transfer_from_account_id.id,
'partner_id': partner.id,
'currency_id': currency.id,
'amount_currency': -amount_currency,
'balance': -amount,
}),
]
sign = -1 if amount < 0 else 1
# By default, the partner of the destination line will be the same than the one of the source line.
# As the transfer move should be balanced, the amounts defined here represent the total amount that
# will be in the destination lines.
# They may be split between several partners though.
default_destination_data[partner, currency, sign] = {
'balance': amount,
'amount_currency': amount_currency,
}
# Run through the lines linked to the reconcile account to determine the possible partners and amounts for the destination
# lines of the transfer
for (partner, currency), lines_to_transfer_partner in groupby(other_lines, lambda l: (l.partner_id, l.currency_id)):
amount = -sum(line.amount_residual for line in lines_to_transfer_partner)
amount_currency = -sum(line.amount_residual_currency for line in lines_to_transfer_partner)
sign = -1 if amount < 0 else 1
# If the partner, currency and sign of the amounts match one of the default data, it is kept for the destination lines.
# The amounts will be adapted with the remaining amounts.
if (partner, currency, sign) in default_destination_data:
default_amount = default_destination_data[partner, currency, sign]['balance']
default_amount_currency = default_destination_data[partner, currency, sign]['amount_currency']
amount_to_transfer = min(abs(amount), abs(default_amount)) * sign
amount_currency_to_transfer = min(abs(amount_currency), abs(default_amount_currency)) * sign
destination_data[partner, currency, sign] = {
'balance': amount_to_transfer,
'amount_currency': amount_currency_to_transfer,
}
default_amount -= amount_to_transfer
default_amount_currency -= amount_currency_to_transfer
amount -= amount_to_transfer
amount_currency -= amount_currency_to_transfer
if not currency.is_zero(abs(default_amount)):
# Update the default amounts with the remaining amounts that have not been kept
default_destination_data[partner, currency, sign] = {
'balance': default_amount,
'amount_currency': default_amount_currency,
}
else:
# Delete the key if there is no remaining amount
del default_destination_data[partner, currency, sign]
if not currency.is_zero(abs(amount)):
amounts_by_partner[currency, sign][partner] = {
'balance': amount,
'amount_currency': amount_currency,
}
# Run through what's left of the default data and try to find a partner that matches the currency and sign.
# If none is found, the partner from the source line is used.
for (partner, currency, sign) in default_destination_data:
amount = default_destination_data[partner, currency, sign]['balance']
amount_currency = default_destination_data[partner, currency, sign]['amount_currency']
# Loop on all the partners with the same currency and sign
for p in amounts_by_partner[currency, sign]:
if amount == 0:
break
amount_to_transfer = min(abs(amount), abs(amounts_by_partner[currency, sign][p]['balance'])) * sign
amount_currency_to_transfer = min(abs(amount_currency), abs(amounts_by_partner[currency, sign][p]['amount_currency'])) * sign
amount -= amount_to_transfer
amount_currency -= amount_currency_to_transfer
destination_data[p, currency, sign]['balance'] += amount_to_transfer
destination_data[p, currency, sign]['amount_currency'] += amount_currency_to_transfer
amounts_by_partner[currency, sign][p]['balance'] -= amount_to_transfer
amounts_by_partner[currency, sign][p]['amount_currency'] -= amount_currency_to_transfer
# Residual amount that couldn't be associated to another partner keeps the partner from the source line
if not currency.is_zero(abs(amount)):
destination_data[partner, currency, sign]['balance'] += amount
destination_data[partner, currency, sign]['amount_currency'] += amount_currency
# Create the destination lines
for (partner, currency, sign) in destination_data:
line_ids += [
Command.create({
'name': _('Transfer from %s', self.transfer_from_account_id.display_name),
'account_id': self.reco_account_id.id,
'partner_id': partner.id,
'currency_id': currency.id,
'amount_currency': destination_data[partner, currency, sign]['amount_currency'],
'balance': destination_data[partner, currency, sign]['balance'],
}),
]
transfer_vals = {
'journal_id': self.journal_id.id,
'company_id': self.company_id.id,
'date': self._get_date_after_lock_date() or self.date,
'line_ids': line_ids,
}
transfer_move = self.env['account.move'].create(transfer_vals)
transfer_move.action_post()
return transfer_move
def reconcile(self):
""" Reconcile selected moves, with a transfer and/or write-off move if necessary."""
self.ensure_one()
move_lines_to_reconcile = self.move_line_ids._origin
do_transfer = self.is_transfer_required
do_write_off = self.edit_mode or (self.is_write_off_required and not self.allow_partials)
if do_transfer:
transfer_move = self.create_transfer()
lines_to_transfer = move_lines_to_reconcile \
.filtered(lambda line: line.account_id == self.transfer_from_account_id)
transfer_line_from = transfer_move.line_ids \
.filtered(lambda line: line.account_id == self.transfer_from_account_id)
transfer_line_to = transfer_move.line_ids \
.filtered(lambda line: line.account_id == self.reco_account_id)
(lines_to_transfer + transfer_line_from).reconcile()
move_lines_to_reconcile = move_lines_to_reconcile - lines_to_transfer + transfer_line_to
if do_write_off:
write_off_move = self.create_write_off()
write_off_line_to_reconcile = write_off_move.line_ids[0]
move_lines_to_reconcile += write_off_line_to_reconcile
amls_plan = [[move_lines_to_reconcile, write_off_line_to_reconcile]]
else:
amls_plan = [move_lines_to_reconcile]
self.env['account.move.line']._reconcile_plan(amls_plan)
return move_lines_to_reconcile if not do_transfer else (move_lines_to_reconcile + transfer_move.line_ids)
def reconcile_open(self):
""" Reconcile selected move lines and open them in dedicated view. """
self.ensure_one()
return self.reconcile().open_reconcile_view()