# Fusion Accounting - Automatic Reconciliation Wizard # Enables batch reconciliation of journal items using configurable # matching strategies (perfect match or zero-balance clearing). from datetime import date from odoo import api, Command, fields, models, _ from odoo.exceptions import UserError class AccountAutoReconcileWizard(models.TransientModel): """Wizard for automated matching and reconciliation of journal items. Accessible via Accounting > Actions > Auto-reconcile.""" _name = 'account.auto.reconcile.wizard' _description = 'Account automatic reconciliation wizard' _check_company_auto = True # --- Organizational Fields --- 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', help="Pre-selected journal items used to seed wizard defaults.", ) # --- Filter Parameters --- 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), " "('account_type', '!=', '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', "Perfect Match"), ('zero_balance', "Clear Account"), ], string='Reconcile', required=True, default='one_to_one', help="Choose to match items pairwise by opposite balance, " "or clear accounts where the total balance is zero.", ) # ------------------------------------------------------------------------- # Defaults # ------------------------------------------------------------------------- @api.model def default_get(self, field_names): """Pre-configure the wizard from context domain if available.""" defaults = super().default_get(field_names) ctx_domain = self.env.context.get('domain') if 'line_ids' in field_names and 'line_ids' not in defaults and ctx_domain: matching_lines = self.env['account.move.line'].search(ctx_domain) if matching_lines: defaults.update(self._derive_defaults_from_lines(matching_lines)) defaults['line_ids'] = [Command.set(matching_lines.ids)] return defaults @api.model def _derive_defaults_from_lines(self, move_lines): """Infer wizard preset values from a set of journal items. When all items share a common account or partner, pre-fill those filters automatically.""" unique_accounts = move_lines.mapped('account_id') unique_partners = move_lines.mapped('partner_id') total_balance = sum(move_lines.mapped('balance')) all_dates = move_lines.mapped('date') preset = { 'account_ids': ( [Command.set(unique_accounts.ids)] if len(unique_accounts) == 1 else [] ), 'partner_ids': ( [Command.set(unique_partners.ids)] if len(unique_partners) == 1 else [] ), 'search_mode': ( 'zero_balance' if move_lines.company_currency_id.is_zero(total_balance) else 'one_to_one' ), 'from_date': min(all_dates), 'to_date': max(all_dates), } return preset def _snapshot_wizard_config(self): """Capture current wizard state as a comparable dict.""" 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, } # Keep backward-compatible alias _get_wizard_values = _snapshot_wizard_config _get_default_wizard_values = _derive_defaults_from_lines # ------------------------------------------------------------------------- # Domain Construction # ------------------------------------------------------------------------- def _get_amls_domain(self): """Build a search domain for journal items eligible for automatic reconciliation.""" self.ensure_one() # If the config hasn't changed from the seed defaults, use IDs directly if ( self.line_ids and self._snapshot_wizard_config() == self._derive_defaults_from_lines(self.line_ids) ): return [('id', 'in', self.line_ids.ids)] base_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), ] if self.account_ids: base_domain.append(('account_id', 'in', self.account_ids.ids)) if self.partner_ids: base_domain.append(('partner_id', 'in', self.partner_ids.ids)) return base_domain # ------------------------------------------------------------------------- # Reconciliation Strategies # ------------------------------------------------------------------------- def _auto_reconcile_one_to_one(self): """Pair items with matching opposite amounts and reconcile them. Groups by account, partner, currency, and absolute residual amount, then pairs positive with negative items chronologically.""" AML = self.env['account.move.line'] grouped_data = AML._read_group( self._get_amls_domain(), ['account_id', 'partner_id', 'currency_id', 'amount_residual_currency:abs_rounded'], ['id:recordset'], ) matched_lines = AML paired_groups = [] for *_grouping, item_set in grouped_data: pos_items = item_set.filtered( lambda ln: ln.amount_residual_currency >= 0 ).sorted('date') neg_items = (item_set - pos_items).sorted('date') pair_count = min(len(pos_items), len(neg_items)) trimmed_pos = pos_items[:pair_count] trimmed_neg = neg_items[:pair_count] matched_lines += trimmed_pos + trimmed_neg for p_item, n_item in zip(trimmed_pos, trimmed_neg): paired_groups.append(p_item + n_item) AML._reconcile_plan(paired_groups) return matched_lines def _auto_reconcile_zero_balance(self): """Reconcile all items within groups (account/partner/currency) where the total residual sums to zero.""" AML = self.env['account.move.line'] grouped_data = AML._read_group( self._get_amls_domain(), groupby=['account_id', 'partner_id', 'currency_id'], aggregates=['id:recordset'], having=[('amount_residual_currency:sum_rounded', '=', 0)], ) matched_lines = AML reconcile_groups = [] for group_row in grouped_data: group_amls = group_row[-1] matched_lines += group_amls reconcile_groups.append(group_amls) AML._reconcile_plan(reconcile_groups) return matched_lines # ------------------------------------------------------------------------- # Main Entry Point # ------------------------------------------------------------------------- def auto_reconcile(self): """Execute the selected reconciliation strategy and display results.""" self.ensure_one() if self.search_mode == 'zero_balance': reconciled = self._auto_reconcile_zero_balance() else: reconciled = self._auto_reconcile_one_to_one() # Gather all related lines (including exchange diff entries) related_lines = self.env['account.move.line'].search([ ('full_reconcile_id', 'in', reconciled.full_reconcile_id.ids), ]) if not related_lines: raise UserError(_("No matching entries found for reconciliation.")) 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', related_lines.ids)], }