Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View 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)],
}