from datetime import date from odoo import api, Command, fields, models, _ from odoo.exceptions import UserError class AccountAutoReconcileWizard(models.TransientModel): """ This wizard is used to automatically reconcile account.move.line. It is accessible trough Accounting > Accounting tab > Actions > Auto-reconcile menuitem. """ _name = 'account.auto.reconcile.wizard' _description = 'Account automatic reconciliation wizard' _check_company_auto = True company_id = fields.Many2one( comodel_name='res.company', required=True, readonly=True, default=lambda self: self.env.company, ) line_ids = fields.Many2many(comodel_name='account.move.line') # Amls from which we derive a preset for the wizard from_date = fields.Date(string='From') to_date = fields.Date(string='To', default=fields.Date.context_today, required=True) account_ids = fields.Many2many( comodel_name='account.account', string='Accounts', check_company=True, domain="[('reconcile', '=', True), ('deprecated', '=', False), ('internal_group', '!=', 'off_balance')]", ) partner_ids = fields.Many2many( comodel_name='res.partner', string='Partners', check_company=True, domain="[('company_id', 'in', (False, company_id)), '|', ('parent_id', '=', False), ('is_company', '=', True)]", ) search_mode = fields.Selection( selection=[ ('one_to_one', 'Opposite balances one by one'), ('zero_balance', 'Accounts with zero balances'), ], string='Reconcile', required=True, default='one_to_one', ) @api.model def default_get(self, fields_list): res = super().default_get(fields_list) domain = self.env.context.get('domain') if 'line_ids' in fields_list and 'line_ids' not in res and domain: amls = self.env['account.move.line'].search(domain) if amls: # pre-configure the wizard res.update(self._get_default_wizard_values(amls)) res['line_ids'] = [Command.set(amls.ids)] return res @api.model def _get_default_wizard_values(self, amls): """ Derive a preset configuration based on amls. For example if all amls have the same account_id we will set it in the wizard. :param amls: account move lines from which we will derive a preset :return: a dict with preset values """ return { 'account_ids': [Command.set(amls[0].account_id.ids)] if all(aml.account_id == amls[0].account_id for aml in amls) else [], 'partner_ids': [Command.set(amls[0].partner_id.ids)] if all(aml.partner_id == amls[0].partner_id for aml in amls) else [], 'search_mode': 'zero_balance' if amls.company_currency_id.is_zero(sum(amls.mapped('balance'))) else 'one_to_one', 'from_date': min(amls.mapped('date')), 'to_date': max(amls.mapped('date')), } def _get_wizard_values(self): """ Get the current configuration of the wizard as a dict of values. :return: a dict with the current configuration of the wizard. """ self.ensure_one() return { 'account_ids': [Command.set(self.account_ids.ids)] if self.account_ids else [], 'partner_ids': [Command.set(self.partner_ids.ids)] if self.partner_ids else [], 'search_mode': self.search_mode, 'from_date': self.from_date, 'to_date': self.to_date, } # ==== Business methods ==== def _get_amls_domain(self): """ Get the domain of amls to be auto-reconciled. """ self.ensure_one() if self.line_ids and self._get_wizard_values() == self._get_default_wizard_values(self.line_ids): domain = [('id', 'in', self.line_ids.ids)] else: domain = [ ('company_id', '=', self.company_id.id), ('parent_state', '=', 'posted'), ('display_type', 'not in', ('line_section', 'line_note')), ('date', '>=', self.from_date or date.min), ('date', '<=', self.to_date), ('reconciled', '=', False), ('account_id.reconcile', '=', True), ('amount_residual_currency', '!=', 0.0), ('amount_residual', '!=', 0.0), # excludes exchange difference lines ] if self.account_ids: domain.append(('account_id', 'in', self.account_ids.ids)) if self.partner_ids: domain.append(('partner_id', 'in', self.partner_ids.ids)) return domain def _auto_reconcile_one_to_one(self): """ Auto-reconcile with one-to-one strategy: We will reconcile 2 amls together if their combined balance is zero. :return: a recordset of reconciled amls """ grouped_amls_data = self.env['account.move.line']._read_group( self._get_amls_domain(), ['account_id', 'partner_id', 'currency_id', 'amount_residual_currency:abs_rounded'], ['id:recordset'], ) all_reconciled_amls = self.env['account.move.line'] amls_grouped_by_2 = [] # we need to group amls with right format for _reconcile_plan for *__, grouped_aml_ids in grouped_amls_data: positive_amls = grouped_aml_ids.filtered(lambda aml: aml.amount_residual_currency >= 0).sorted('date') negative_amls = (grouped_aml_ids - positive_amls).sorted('date') min_len = min(len(positive_amls), len(negative_amls)) positive_amls = positive_amls[:min_len] negative_amls = negative_amls[:min_len] all_reconciled_amls += positive_amls + negative_amls amls_grouped_by_2 += [pos_aml + neg_aml for (pos_aml, neg_aml) in zip(positive_amls, negative_amls)] self.env['account.move.line']._reconcile_plan(amls_grouped_by_2) return all_reconciled_amls def _auto_reconcile_zero_balance(self): """ Auto-reconcile with zero balance strategy: We will reconcile all amls grouped by currency/account/partner that have a total balance of zero. :return: a recordset of reconciled amls """ grouped_amls_data = self.env['account.move.line']._read_group( self._get_amls_domain(), groupby=['account_id', 'partner_id', 'currency_id'], aggregates=['id:recordset'], having=[('amount_residual_currency:sum_rounded', '=', 0)], ) all_reconciled_amls = self.env['account.move.line'] amls_grouped_together = [] # we need to group amls with right format for _reconcile_plan for aml_data in grouped_amls_data: all_reconciled_amls += aml_data[-1] amls_grouped_together += [aml_data[-1]] self.env['account.move.line']._reconcile_plan(amls_grouped_together) return all_reconciled_amls def auto_reconcile(self): """ Automatically reconcile amls given wizard's parameters. :return: an action that opens all reconciled items and related amls (exchange diff, etc) """ self.ensure_one() if self.search_mode == 'zero_balance': reconciled_amls = self._auto_reconcile_zero_balance() else: # search_mode == 'one_to_one' reconciled_amls = self._auto_reconcile_one_to_one() reconciled_amls_and_related = self.env['account.move.line'].search([ ('full_reconcile_id', 'in', reconciled_amls.full_reconcile_id.ids) ]) if reconciled_amls_and_related: return { 'name': _("Automatically Reconciled Entries"), 'type': 'ir.actions.act_window', 'res_model': 'account.move.line', 'context': "{'search_default_group_by_matching': True}", 'view_mode': 'list', 'domain': [('id', 'in', reconciled_amls_and_related.ids)], } else: raise UserError("Nothing to reconcile.")