From 33ba2de5dd0539a179025bf3442c9a241dbcd721 Mon Sep 17 00:00:00 2001 From: git_admin Date: Tue, 28 Apr 2026 07:34:11 +0000 Subject: [PATCH] Tower: upload at_accounting 18.0.1.7 (via marketplace) --- .../wizard/account_reconcile_wizard.py | 683 ++++++++++++++++++ 1 file changed, 683 insertions(+) create mode 100644 addons/at_accounting/wizard/account_reconcile_wizard.py diff --git a/addons/at_accounting/wizard/account_reconcile_wizard.py b/addons/at_accounting/wizard/account_reconcile_wizard.py new file mode 100644 index 0000000..2276c62 --- /dev/null +++ b/addons/at_accounting/wizard/account_reconcile_wizard.py @@ -0,0 +1,683 @@ +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_list): + res = super().default_get(fields_list) + if 'move_line_ids' not in fields_list: + 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="[('deprecated', '=', False), ('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 + } + + disable_partial_exchange_diff = bool(self.env['ir.config_parameter'].sudo().get_param('account.disable_partial_exchange_diff')) + plan = plan_list[0] + # residuals are subtracted from aml_values_map + amls\ + .with_context(no_exchange_difference=self._context.get('no_exchange_difference') or disable_partial_exchange_diff) \ + ._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 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') + def _compute_reco_model_autocomplete_ids(self): + """ Computes available reconcile models, we only take models that are of type 'writeoff_button' + and that have one (and only one) line. + """ + for wizard in self: + domain = [ + ('rule_type', '=', 'writeoff_button'), + ('company_id', '=', wizard.company_id.id), + ('counterpart_type', 'not in', ('sale', 'purchase')), + ] + query = self.env['account.reconcile.model']._where_calc(domain) + 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.to_check = self.reco_model_id.to_check + 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.journal_id = self.reco_model_id.line_ids.journal_id # we limited models to those with one and only one line + 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, + '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. + """ + self.ensure_one() + # we create one transfer per partner to keep + line_ids = [] + lines_to_transfer = self.move_line_ids.filtered(lambda line: line.account_id == self.transfer_from_account_id) + 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 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': amount_currency, + 'balance': amount, + }), + 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, + }), + ] + 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()