Initial commit
This commit is contained in:
245
Fusion Accounting/wizard/account_auto_reconcile_wizard.py
Normal file
245
Fusion Accounting/wizard/account_auto_reconcile_wizard.py
Normal file
@@ -0,0 +1,245 @@
|
||||
# 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)],
|
||||
}
|
||||
Reference in New Issue
Block a user