246 lines
9.1 KiB
Python
246 lines
9.1 KiB
Python
# 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)],
|
|
}
|