2101 lines
89 KiB
Python
2101 lines
89 KiB
Python
# Fusion Accounting - Bank Reconciliation Widget
|
|
# Original implementation for Fusion Accounting module
|
|
|
|
import json
|
|
import markupsafe
|
|
from collections import defaultdict
|
|
from contextlib import contextmanager
|
|
|
|
from odoo import _, api, fields, models, Command
|
|
from odoo.addons.web.controllers.utils import clean_action
|
|
from odoo.exceptions import UserError, RedirectWarning
|
|
from odoo.tools.misc import formatLang
|
|
|
|
|
|
class FusionBankRecWidget(models.Model):
|
|
"""Manages the reconciliation process for a single bank statement line.
|
|
|
|
This transient-like model orchestrates the matching of a bank statement
|
|
entry against existing journal items, write-off entries, and other
|
|
counterparts. It exists only in memory and is never persisted.
|
|
|
|
The widget maintains a collection of 'bank.rec.widget.line' entries
|
|
representing the reconciliation breakdown: the original bank entry,
|
|
matched journal items, manual adjustments, tax entries, exchange
|
|
differences, and a system-generated balancing entry.
|
|
"""
|
|
|
|
_name = "bank.rec.widget"
|
|
_description = "Fusion bank reconciliation widget"
|
|
|
|
_auto = False
|
|
_table_query = "0"
|
|
|
|
# =========================================================================
|
|
# FIELDS: Statement Line Reference
|
|
# =========================================================================
|
|
|
|
st_line_id = fields.Many2one(comodel_name='account.bank.statement.line')
|
|
move_id = fields.Many2one(
|
|
related='st_line_id.move_id',
|
|
depends=['st_line_id'],
|
|
)
|
|
st_line_checked = fields.Boolean(
|
|
related='st_line_id.move_id.checked',
|
|
depends=['st_line_id'],
|
|
)
|
|
st_line_is_reconciled = fields.Boolean(
|
|
related='st_line_id.is_reconciled',
|
|
depends=['st_line_id'],
|
|
)
|
|
st_line_journal_id = fields.Many2one(
|
|
related='st_line_id.journal_id',
|
|
depends=['st_line_id'],
|
|
)
|
|
st_line_transaction_details = fields.Html(
|
|
compute='_compute_st_line_transaction_details',
|
|
)
|
|
partner_name = fields.Char(related='st_line_id.partner_name')
|
|
|
|
# =========================================================================
|
|
# FIELDS: Currency & Company
|
|
# =========================================================================
|
|
|
|
transaction_currency_id = fields.Many2one(
|
|
comodel_name='res.currency',
|
|
compute='_compute_transaction_currency_id',
|
|
)
|
|
journal_currency_id = fields.Many2one(
|
|
comodel_name='res.currency',
|
|
compute='_compute_journal_currency_id',
|
|
)
|
|
company_id = fields.Many2one(
|
|
comodel_name='res.company',
|
|
related='st_line_id.company_id',
|
|
depends=['st_line_id'],
|
|
)
|
|
country_code = fields.Char(
|
|
related='company_id.country_id.code',
|
|
depends=['company_id'],
|
|
)
|
|
company_currency_id = fields.Many2one(
|
|
string="Wizard Company Currency",
|
|
related='company_id.currency_id',
|
|
depends=['st_line_id'],
|
|
)
|
|
|
|
# =========================================================================
|
|
# FIELDS: Partner & Lines
|
|
# =========================================================================
|
|
|
|
partner_id = fields.Many2one(
|
|
comodel_name='res.partner',
|
|
string="Partner",
|
|
compute='_compute_partner_id',
|
|
store=True,
|
|
readonly=False,
|
|
)
|
|
line_ids = fields.One2many(
|
|
comodel_name='bank.rec.widget.line',
|
|
inverse_name='wizard_id',
|
|
compute='_compute_line_ids',
|
|
compute_sudo=False,
|
|
store=True,
|
|
readonly=False,
|
|
)
|
|
|
|
# =========================================================================
|
|
# FIELDS: Reconciliation Models
|
|
# =========================================================================
|
|
|
|
available_reco_model_ids = fields.Many2many(
|
|
comodel_name='account.reconcile.model',
|
|
compute='_compute_available_reco_model_ids',
|
|
store=True,
|
|
readonly=False,
|
|
)
|
|
selected_reco_model_id = fields.Many2one(
|
|
comodel_name='account.reconcile.model',
|
|
compute='_compute_selected_reco_model_id',
|
|
)
|
|
matching_rules_allow_auto_reconcile = fields.Boolean()
|
|
|
|
# =========================================================================
|
|
# FIELDS: State & Display
|
|
# =========================================================================
|
|
|
|
state = fields.Selection(
|
|
selection=[
|
|
('invalid', "Invalid"),
|
|
('valid', "Valid"),
|
|
('reconciled', "Reconciled"),
|
|
],
|
|
compute='_compute_state',
|
|
store=True,
|
|
help=(
|
|
"Invalid: Cannot validate because the suspense account is still present.\n"
|
|
"Valid: Ready for validation.\n"
|
|
"Reconciled: Already processed, no action needed."
|
|
),
|
|
)
|
|
is_multi_currency = fields.Boolean(compute='_compute_is_multi_currency')
|
|
|
|
# =========================================================================
|
|
# FIELDS: JS Interface
|
|
# =========================================================================
|
|
|
|
selected_aml_ids = fields.Many2many(
|
|
comodel_name='account.move.line',
|
|
compute='_compute_selected_aml_ids',
|
|
)
|
|
todo_command = fields.Json(store=False)
|
|
return_todo_command = fields.Json(store=False)
|
|
form_index = fields.Char()
|
|
|
|
# =========================================================================
|
|
# COMPUTE METHODS
|
|
# =========================================================================
|
|
|
|
@api.depends('st_line_id')
|
|
def _compute_line_ids(self):
|
|
"""Build the initial set of reconciliation entries from the statement line.
|
|
|
|
Creates the liquidity entry (the bank-side journal item) and loads any
|
|
existing reconciled counterparts. For already-reconciled lines, exchange
|
|
difference entries are separated out for clear display.
|
|
"""
|
|
for rec_widget in self:
|
|
if not rec_widget.st_line_id:
|
|
rec_widget.line_ids = [Command.clear()]
|
|
continue
|
|
|
|
# Start with the liquidity (bank account) entry
|
|
orm_ops = [
|
|
Command.clear(),
|
|
Command.create(rec_widget._lines_prepare_liquidity_line()),
|
|
]
|
|
|
|
# Load existing counterpart entries for reconciled lines
|
|
_liq_items, _suspense_items, matched_items = rec_widget.st_line_id._seek_for_lines()
|
|
for matched_item in matched_items:
|
|
partial_links = matched_item.matched_debit_ids + matched_item.matched_credit_ids
|
|
fx_correction_items = (
|
|
partial_links.exchange_move_id.line_ids
|
|
.filtered(lambda item: item.account_id != matched_item.account_id)
|
|
)
|
|
if rec_widget.state == 'reconciled' and fx_correction_items:
|
|
# Display the original amounts separately from exchange adjustments
|
|
adjusted_balance = matched_item.balance - sum(fx_correction_items.mapped('balance'))
|
|
adjusted_foreign = matched_item.amount_currency - sum(fx_correction_items.mapped('amount_currency'))
|
|
orm_ops.append(Command.create(
|
|
rec_widget._lines_prepare_aml_line(
|
|
matched_item,
|
|
balance=adjusted_balance,
|
|
amount_currency=adjusted_foreign,
|
|
)
|
|
))
|
|
for fx_item in fx_correction_items:
|
|
orm_ops.append(Command.create(rec_widget._lines_prepare_aml_line(fx_item)))
|
|
else:
|
|
orm_ops.append(Command.create(rec_widget._lines_prepare_aml_line(matched_item)))
|
|
|
|
rec_widget.line_ids = orm_ops
|
|
rec_widget._lines_add_auto_balance_line()
|
|
|
|
@api.depends('st_line_id')
|
|
def _compute_available_reco_model_ids(self):
|
|
"""Find reconciliation models applicable to the current journal and company."""
|
|
for rec_widget in self:
|
|
if not rec_widget.st_line_id:
|
|
rec_widget.available_reco_model_ids = [Command.clear()]
|
|
continue
|
|
|
|
stmt_entry = rec_widget.st_line_id
|
|
applicable_models = self.env['account.reconcile.model'].search([
|
|
('rule_type', '=', 'writeoff_button'),
|
|
('company_id', '=', stmt_entry.company_id.id),
|
|
'|',
|
|
('match_journal_ids', '=', False),
|
|
('match_journal_ids', '=', stmt_entry.journal_id.id),
|
|
])
|
|
# Keep models that are general-purpose or use at most one journal
|
|
applicable_models = applicable_models.filtered(
|
|
lambda model: (
|
|
model.counterpart_type == 'general'
|
|
or len(model.line_ids.journal_id) <= 1
|
|
)
|
|
)
|
|
rec_widget.available_reco_model_ids = [Command.set(applicable_models.ids)]
|
|
|
|
@api.depends('line_ids.reconcile_model_id')
|
|
def _compute_selected_reco_model_id(self):
|
|
"""Track which write-off reconciliation model is currently applied."""
|
|
for rec_widget in self:
|
|
active_models = rec_widget.line_ids.reconcile_model_id.filtered(
|
|
lambda model: model.rule_type == 'writeoff_button'
|
|
)
|
|
rec_widget.selected_reco_model_id = (
|
|
active_models.id if len(active_models) == 1 else None
|
|
)
|
|
|
|
@api.depends('st_line_id', 'line_ids.account_id')
|
|
def _compute_state(self):
|
|
"""Determine the reconciliation state of the widget.
|
|
|
|
- 'reconciled': Statement line is already fully matched
|
|
- 'invalid': Suspense account is still present (not fully allocated)
|
|
- 'valid': All amounts allocated to real accounts, ready to validate
|
|
"""
|
|
for rec_widget in self:
|
|
if not rec_widget.st_line_id:
|
|
rec_widget.state = 'invalid'
|
|
elif rec_widget.st_line_id.is_reconciled:
|
|
rec_widget.state = 'reconciled'
|
|
else:
|
|
holding_account = rec_widget.st_line_id.journal_id.suspense_account_id
|
|
accounts_in_use = rec_widget.line_ids.account_id
|
|
rec_widget.state = 'invalid' if holding_account in accounts_in_use else 'valid'
|
|
|
|
@api.depends('st_line_id')
|
|
def _compute_journal_currency_id(self):
|
|
"""Resolve the effective currency of the bank journal."""
|
|
for rec_widget in self:
|
|
journal = rec_widget.st_line_id.journal_id
|
|
rec_widget.journal_currency_id = journal.currency_id or journal.company_id.currency_id
|
|
|
|
@api.depends('st_line_id')
|
|
def _compute_st_line_transaction_details(self):
|
|
"""Render the raw transaction details as formatted HTML."""
|
|
for rec_widget in self:
|
|
rec_widget.st_line_transaction_details = rec_widget._render_transaction_details()
|
|
|
|
@api.depends('st_line_id')
|
|
def _compute_transaction_currency_id(self):
|
|
"""Determine the transaction currency (foreign currency if set, else journal currency)."""
|
|
for rec_widget in self:
|
|
rec_widget.transaction_currency_id = (
|
|
rec_widget.st_line_id.foreign_currency_id or rec_widget.journal_currency_id
|
|
)
|
|
|
|
@api.depends('st_line_id')
|
|
def _compute_partner_id(self):
|
|
"""Auto-detect the partner from the statement line data."""
|
|
for rec_widget in self:
|
|
if rec_widget.st_line_id:
|
|
rec_widget.partner_id = rec_widget.st_line_id._retrieve_partner()
|
|
else:
|
|
rec_widget.partner_id = None
|
|
|
|
@api.depends('company_id')
|
|
def _compute_is_multi_currency(self):
|
|
"""Check if the user has multi-currency access rights."""
|
|
self.is_multi_currency = self.env.user.has_groups('base.group_multi_currency')
|
|
|
|
@api.depends('company_id', 'line_ids.source_aml_id')
|
|
def _compute_selected_aml_ids(self):
|
|
"""Expose the set of journal items currently matched in this widget."""
|
|
for rec_widget in self:
|
|
rec_widget.selected_aml_ids = [Command.set(rec_widget.line_ids.source_aml_id.ids)]
|
|
|
|
# =========================================================================
|
|
# TRANSACTION DETAILS RENDERING
|
|
# =========================================================================
|
|
|
|
def _render_transaction_details(self):
|
|
"""Convert structured transaction details into a readable HTML tree.
|
|
|
|
Parses the JSON/dict transaction_details field from the statement line
|
|
and renders it as a nested HTML list, filtering out empty values.
|
|
"""
|
|
self.ensure_one()
|
|
raw_details = self.st_line_id.transaction_details
|
|
if not raw_details:
|
|
return None
|
|
|
|
parsed = json.loads(raw_details) if isinstance(raw_details, str) else raw_details
|
|
|
|
def _build_html_node(label, data):
|
|
"""Recursively build HTML list items from a data structure."""
|
|
if not data:
|
|
return ""
|
|
if isinstance(data, dict):
|
|
children = markupsafe.Markup("").join(
|
|
_build_html_node(f"{key}: ", val) for key, val in data.items()
|
|
)
|
|
rendered_value = markupsafe.Markup('<ol>%s</ol>') % children if children else ""
|
|
elif isinstance(data, (list, tuple)):
|
|
children = markupsafe.Markup("").join(
|
|
_build_html_node(f"{pos}: ", val) for pos, val in enumerate(data, start=1)
|
|
)
|
|
rendered_value = markupsafe.Markup('<ol>%s</ol>') % children if children else ""
|
|
else:
|
|
rendered_value = data
|
|
|
|
if not rendered_value:
|
|
return ""
|
|
|
|
return markupsafe.Markup(
|
|
'<li style="list-style-type: none">'
|
|
'<span><span class="fw-bolder">%(label)s</span>%(content)s</span>'
|
|
'</li>'
|
|
) % {'label': label, 'content': rendered_value}
|
|
|
|
root_html = _build_html_node('', parsed)
|
|
return markupsafe.Markup("<ol>%s</ol>") % root_html
|
|
|
|
# =========================================================================
|
|
# ONCHANGE HANDLERS
|
|
# =========================================================================
|
|
|
|
@api.onchange('todo_command')
|
|
def _onchange_todo_command(self):
|
|
"""Dispatch JS-triggered commands to the appropriate handler method.
|
|
|
|
The JS frontend sends commands via the todo_command field. Each command
|
|
specifies a method_name that maps to a _js_action_* method, along with
|
|
optional args and kwargs.
|
|
"""
|
|
self.ensure_one()
|
|
pending_cmd = self.todo_command
|
|
self.todo_command = None
|
|
self.return_todo_command = None
|
|
|
|
# Force-load line_ids to prevent stale cache issues during updates
|
|
self._ensure_loaded_lines()
|
|
|
|
action_method = getattr(self, f'_js_action_{pending_cmd["method_name"]}')
|
|
action_method(*pending_cmd.get('args', []), **pending_cmd.get('kwargs', {}))
|
|
|
|
# =========================================================================
|
|
# LOW-LEVEL OVERRIDES
|
|
# =========================================================================
|
|
|
|
@api.model
|
|
def new(self, values=None, origin=None, ref=None):
|
|
"""Override to ensure line_ids are loaded immediately after creation."""
|
|
widget = super().new(values=values, origin=origin, ref=ref)
|
|
# Trigger line_ids evaluation to prevent cache inconsistencies
|
|
# when subsequent operations modify the One2many
|
|
widget.line_ids
|
|
return widget
|
|
|
|
# =========================================================================
|
|
# INITIALIZATION
|
|
# =========================================================================
|
|
|
|
@api.model
|
|
def fetch_initial_data(self):
|
|
"""Prepare field metadata and default values for the JS frontend.
|
|
|
|
Returns a dictionary with field definitions (including related fields
|
|
for One2many and Many2many) and initial values for bootstrapping the
|
|
reconciliation widget on the client side.
|
|
"""
|
|
field_defs = self.fields_get()
|
|
view_attrs = self.env['ir.ui.view']._get_view_field_attributes()
|
|
|
|
for fname, field_obj in self._fields.items():
|
|
if field_obj.type == 'one2many':
|
|
child_fields = self[fname].fields_get(attributes=view_attrs)
|
|
# Remove the back-reference field from child definitions
|
|
child_fields.pop(field_obj.inverse_name, None)
|
|
field_defs[fname]['relatedFields'] = child_fields
|
|
# Resolve nested Many2many related fields
|
|
for child_fname, child_field_obj in self[fname]._fields.items():
|
|
if child_field_obj.type == "many2many":
|
|
nested_model = self.env[child_field_obj.comodel_name]
|
|
field_defs[fname]['relatedFields'][child_fname]['relatedFields'] = (
|
|
nested_model.fields_get(
|
|
allfields=['id', 'display_name'],
|
|
attributes=view_attrs,
|
|
)
|
|
)
|
|
elif field_obj.name == 'available_reco_model_ids':
|
|
field_defs[fname]['relatedFields'] = self[fname].fields_get(
|
|
allfields=['id', 'display_name'],
|
|
attributes=view_attrs,
|
|
)
|
|
|
|
# Mark todo_command as triggering onChange
|
|
field_defs['todo_command']['onChange'] = True
|
|
|
|
# Build initial values
|
|
defaults = {}
|
|
for fname, field_obj in self._fields.items():
|
|
if field_obj.type == 'one2many':
|
|
defaults[fname] = []
|
|
else:
|
|
defaults[fname] = field_obj.convert_to_read(self[fname], self, {})
|
|
|
|
return {
|
|
'initial_values': defaults,
|
|
'fields': field_defs,
|
|
}
|
|
|
|
# =========================================================================
|
|
# LINE PREPARATION METHODS
|
|
# =========================================================================
|
|
|
|
def _ensure_loaded_lines(self):
|
|
"""Force evaluation of line_ids to prevent ORM cache inconsistencies.
|
|
|
|
When a One2many field's value is replaced with new Command.create entries,
|
|
accessing the field beforehand can cause stale records to persist alongside
|
|
the new ones. Triggering evaluation here avoids that problem.
|
|
"""
|
|
self.line_ids
|
|
|
|
def _lines_turn_auto_balance_into_manual_line(self, entry):
|
|
"""Promote an auto-balance entry to a manual entry when the user edits it."""
|
|
if entry.flag == 'auto_balance':
|
|
entry.flag = 'manual'
|
|
|
|
def _lines_get_line_in_edit_form(self):
|
|
"""Return the widget entry currently selected for editing, if any."""
|
|
self.ensure_one()
|
|
if not self.form_index:
|
|
return None
|
|
return self.line_ids.filtered(lambda rec: rec.index == self.form_index)
|
|
|
|
def _lines_prepare_aml_line(self, move_line, **extra_vals):
|
|
"""Build creation values for a widget entry linked to a journal item."""
|
|
self.ensure_one()
|
|
return {
|
|
'flag': 'aml',
|
|
'source_aml_id': move_line.id,
|
|
**extra_vals,
|
|
}
|
|
|
|
def _lines_prepare_liquidity_line(self):
|
|
"""Build creation values for the liquidity (bank account) entry.
|
|
|
|
The liquidity entry represents the bank-side journal item. When the
|
|
journal uses a different currency from the transaction, the amounts
|
|
are sourced from the appropriate line of the statement's move.
|
|
"""
|
|
self.ensure_one()
|
|
liq_item, _suspense_items, _matched_items = self.st_line_id._seek_for_lines()
|
|
return self._lines_prepare_aml_line(liq_item, flag='liquidity')
|
|
|
|
def _lines_prepare_auto_balance_line(self):
|
|
"""Compute values for the automatic balancing entry.
|
|
|
|
Calculates the remaining unallocated amount across all current entries
|
|
and produces an auto-balance entry to close the gap. The target account
|
|
is chosen based on the partner's receivable/payable configuration, or
|
|
falls back to the journal's suspense account.
|
|
"""
|
|
self.ensure_one()
|
|
stmt_entry = self.st_line_id
|
|
|
|
# Retrieve the statement line's accounting amounts
|
|
txn_amount, txn_currency, jrnl_amount, _jrnl_currency, comp_amount, _comp_currency = (
|
|
self.st_line_id._get_accounting_amounts_and_currencies()
|
|
)
|
|
|
|
# Calculate the remaining amounts to be balanced
|
|
pending_foreign = -txn_amount
|
|
pending_company = -comp_amount
|
|
|
|
for entry in self.line_ids:
|
|
if entry.flag in ('liquidity', 'auto_balance'):
|
|
continue
|
|
|
|
pending_company -= entry.balance
|
|
|
|
# Convert to transaction currency using the appropriate rate
|
|
txn_to_jrnl_rate = abs(txn_amount / jrnl_amount) if jrnl_amount else 0.0
|
|
txn_to_comp_rate = abs(txn_amount / comp_amount) if comp_amount else 0.0
|
|
|
|
if entry.currency_id == self.transaction_currency_id:
|
|
pending_foreign -= entry.amount_currency
|
|
elif entry.currency_id == self.journal_currency_id:
|
|
pending_foreign -= txn_currency.round(entry.amount_currency * txn_to_jrnl_rate)
|
|
else:
|
|
pending_foreign -= txn_currency.round(entry.balance * txn_to_comp_rate)
|
|
|
|
# Determine the target account based on partner configuration
|
|
target_account = None
|
|
current_partner = self.partner_id
|
|
if current_partner:
|
|
label = _("Open balance of %(amount)s", amount=formatLang(
|
|
self.env, txn_amount, currency_obj=txn_currency,
|
|
))
|
|
scoped_partner = current_partner.with_company(stmt_entry.company_id)
|
|
|
|
has_customer_role = current_partner.customer_rank and not current_partner.supplier_rank
|
|
has_vendor_role = current_partner.supplier_rank and not current_partner.customer_rank
|
|
|
|
if has_customer_role:
|
|
target_account = scoped_partner.property_account_receivable_id
|
|
elif has_vendor_role:
|
|
target_account = scoped_partner.property_account_payable_id
|
|
elif stmt_entry.amount > 0:
|
|
target_account = scoped_partner.property_account_receivable_id
|
|
else:
|
|
target_account = scoped_partner.property_account_payable_id
|
|
|
|
if not target_account:
|
|
label = stmt_entry.payment_ref
|
|
target_account = stmt_entry.journal_id.suspense_account_id
|
|
|
|
return {
|
|
'flag': 'auto_balance',
|
|
'account_id': target_account.id,
|
|
'name': label,
|
|
'amount_currency': pending_foreign,
|
|
'balance': pending_company,
|
|
}
|
|
|
|
def _lines_add_auto_balance_line(self):
|
|
"""Refresh the auto-balance entry to keep the reconciliation balanced.
|
|
|
|
Removes any existing auto-balance entry and creates a new one if the
|
|
remaining balance is non-zero. The entry is always placed last.
|
|
"""
|
|
# Remove existing auto-balance entries
|
|
orm_ops = [
|
|
Command.unlink(entry.id)
|
|
for entry in self.line_ids
|
|
if entry.flag == 'auto_balance'
|
|
]
|
|
|
|
# Create a fresh auto-balance if needed
|
|
balance_data = self._lines_prepare_auto_balance_line()
|
|
if not self.company_currency_id.is_zero(balance_data['balance']):
|
|
orm_ops.append(Command.create(balance_data))
|
|
|
|
self.line_ids = orm_ops
|
|
|
|
def _lines_prepare_new_aml_line(self, move_line, **extra_vals):
|
|
"""Build values for adding a new journal item as a reconciliation counterpart."""
|
|
return self._lines_prepare_aml_line(
|
|
move_line,
|
|
flag='new_aml',
|
|
currency_id=move_line.currency_id.id,
|
|
amount_currency=-move_line.amount_residual_currency,
|
|
balance=-move_line.amount_residual,
|
|
source_amount_currency=-move_line.amount_residual_currency,
|
|
source_balance=-move_line.amount_residual,
|
|
**extra_vals,
|
|
)
|
|
|
|
def _lines_check_partial_amount(self, entry):
|
|
"""Check if a partial reconciliation should be applied to the given entry.
|
|
|
|
Determines whether the matched journal item exceeds the remaining
|
|
transaction amount and, if so, computes the adjusted amounts needed
|
|
for a partial match. Returns None if no partial is needed.
|
|
"""
|
|
if entry.flag != 'new_aml':
|
|
return None
|
|
|
|
fx_entry = self.line_ids.filtered(
|
|
lambda rec: rec.flag == 'exchange_diff' and rec.source_aml_id == entry.source_aml_id
|
|
)
|
|
balance_data = self._lines_prepare_auto_balance_line()
|
|
|
|
remaining_comp = balance_data['balance']
|
|
current_comp = entry.balance + fx_entry.balance
|
|
|
|
# Check if there's excess in company currency
|
|
comp_cur = self.company_currency_id
|
|
excess_debit_comp = (
|
|
comp_cur.compare_amounts(remaining_comp, 0) < 0
|
|
and comp_cur.compare_amounts(current_comp, 0) > 0
|
|
and comp_cur.compare_amounts(current_comp, -remaining_comp) > 0
|
|
)
|
|
excess_credit_comp = (
|
|
comp_cur.compare_amounts(remaining_comp, 0) > 0
|
|
and comp_cur.compare_amounts(current_comp, 0) < 0
|
|
and comp_cur.compare_amounts(-current_comp, remaining_comp) > 0
|
|
)
|
|
|
|
remaining_foreign = balance_data['amount_currency']
|
|
current_foreign = entry.amount_currency
|
|
entry_cur = entry.currency_id
|
|
|
|
# Check if there's excess in the entry's currency
|
|
excess_debit_foreign = (
|
|
entry_cur.compare_amounts(remaining_foreign, 0) < 0
|
|
and entry_cur.compare_amounts(current_foreign, 0) > 0
|
|
and entry_cur.compare_amounts(current_foreign, -remaining_foreign) > 0
|
|
)
|
|
excess_credit_foreign = (
|
|
entry_cur.compare_amounts(remaining_foreign, 0) > 0
|
|
and entry_cur.compare_amounts(current_foreign, 0) < 0
|
|
and entry_cur.compare_amounts(-current_foreign, remaining_foreign) > 0
|
|
)
|
|
|
|
if entry_cur == self.transaction_currency_id:
|
|
if not (excess_debit_foreign or excess_credit_foreign):
|
|
return None
|
|
|
|
adjusted_foreign = current_foreign + remaining_foreign
|
|
|
|
# Use the bank transaction rate for conversion
|
|
txn_amount, _txn_cur, _jrnl_amt, _jrnl_cur, comp_amount, _comp_cur = (
|
|
self.st_line_id._get_accounting_amounts_and_currencies()
|
|
)
|
|
conversion_rate = abs(comp_amount / txn_amount) if txn_amount else 0.0
|
|
|
|
adjusted_comp_total = entry.company_currency_id.round(adjusted_foreign * conversion_rate)
|
|
adjusted_entry_comp = entry.company_currency_id.round(
|
|
adjusted_comp_total * abs(entry.balance) / abs(current_comp)
|
|
)
|
|
adjusted_fx_comp = adjusted_comp_total - adjusted_entry_comp
|
|
|
|
return {
|
|
'exchange_diff_line': fx_entry,
|
|
'amount_currency': adjusted_foreign,
|
|
'balance': adjusted_entry_comp,
|
|
'exchange_balance': adjusted_fx_comp,
|
|
}
|
|
|
|
elif excess_debit_comp or excess_credit_comp:
|
|
adjusted_comp_total = current_comp + remaining_comp
|
|
|
|
# Use the original journal item's rate
|
|
original_rate = abs(entry.source_amount_currency) / abs(entry.source_balance)
|
|
|
|
adjusted_entry_comp = entry.company_currency_id.round(
|
|
adjusted_comp_total * abs(entry.balance) / abs(current_comp)
|
|
)
|
|
adjusted_fx_comp = adjusted_comp_total - adjusted_entry_comp
|
|
adjusted_foreign = entry_cur.round(adjusted_entry_comp * original_rate)
|
|
|
|
return {
|
|
'exchange_diff_line': fx_entry,
|
|
'amount_currency': adjusted_foreign,
|
|
'balance': adjusted_entry_comp,
|
|
'exchange_balance': adjusted_fx_comp,
|
|
}
|
|
|
|
return None
|
|
|
|
def _do_amounts_apply_for_early_payment(self, pending_foreign, discount_total):
|
|
"""Check if the remaining amount exactly matches the early payment discount."""
|
|
return self.transaction_currency_id.compare_amounts(pending_foreign, discount_total) == 0
|
|
|
|
def _lines_check_apply_early_payment_discount(self):
|
|
"""Attempt to apply early payment discount terms to matched journal items.
|
|
|
|
Examines all currently matched journal items to see if their invoices
|
|
offer early payment discounts. If the remaining balance equals the total
|
|
discount amount, applies the discount by creating early_payment entries.
|
|
|
|
Returns True if the discount was applied, False otherwise.
|
|
"""
|
|
matched_entries = self.line_ids.filtered(lambda rec: rec.flag == 'new_aml')
|
|
|
|
# Compute the remaining balance with and without matched entries
|
|
balance_data = self._lines_prepare_auto_balance_line()
|
|
residual_foreign_excl = (
|
|
balance_data['amount_currency'] + sum(matched_entries.mapped('amount_currency'))
|
|
)
|
|
residual_comp_excl = (
|
|
balance_data['balance'] + sum(matched_entries.mapped('balance'))
|
|
)
|
|
residual_foreign_incl = (
|
|
residual_foreign_excl - sum(matched_entries.mapped('source_amount_currency'))
|
|
)
|
|
residual_foreign = residual_foreign_incl
|
|
|
|
uniform_currency = matched_entries.currency_id == self.transaction_currency_id
|
|
has_discount_eligible = False
|
|
|
|
discount_entries = []
|
|
discount_total = 0.0
|
|
|
|
for matched_entry in matched_entries:
|
|
source_item = matched_entry.source_aml_id
|
|
if source_item.move_id._is_eligible_for_early_payment_discount(
|
|
self.transaction_currency_id, self.st_line_id.date
|
|
):
|
|
has_discount_eligible = True
|
|
discount_total += source_item.amount_currency - source_item.discount_amount_currency
|
|
discount_entries.append({
|
|
'aml': source_item,
|
|
'amount_currency': matched_entry.amount_currency,
|
|
'balance': matched_entry.balance,
|
|
})
|
|
|
|
# Remove existing early payment entries
|
|
orm_ops = [
|
|
Command.unlink(entry.id)
|
|
for entry in self.line_ids
|
|
if entry.flag == 'early_payment'
|
|
]
|
|
discount_applied = False
|
|
|
|
if (
|
|
uniform_currency
|
|
and has_discount_eligible
|
|
and self._do_amounts_apply_for_early_payment(residual_foreign, discount_total)
|
|
):
|
|
# Reset matched entries to their full original amounts
|
|
for matched_entry in matched_entries:
|
|
matched_entry.amount_currency = matched_entry.source_amount_currency
|
|
matched_entry.balance = matched_entry.source_balance
|
|
|
|
# Generate the early payment discount entries
|
|
discount_breakdown = (
|
|
self.env['account.move']._get_invoice_counterpart_amls_for_early_payment_discount(
|
|
discount_entries,
|
|
residual_comp_excl - sum(matched_entries.mapped('source_balance')),
|
|
)
|
|
)
|
|
|
|
for category_entries in discount_breakdown.values():
|
|
for entry_vals in category_entries:
|
|
orm_ops.append(Command.create({
|
|
'flag': 'early_payment',
|
|
'account_id': entry_vals['account_id'],
|
|
'date': self.st_line_id.date,
|
|
'name': entry_vals['name'],
|
|
'partner_id': entry_vals['partner_id'],
|
|
'currency_id': entry_vals['currency_id'],
|
|
'amount_currency': entry_vals['amount_currency'],
|
|
'balance': entry_vals['balance'],
|
|
'analytic_distribution': entry_vals.get('analytic_distribution'),
|
|
'tax_ids': entry_vals.get('tax_ids', []),
|
|
'tax_tag_ids': entry_vals.get('tax_tag_ids', []),
|
|
'tax_repartition_line_id': entry_vals.get('tax_repartition_line_id'),
|
|
'group_tax_id': entry_vals.get('group_tax_id'),
|
|
}))
|
|
discount_applied = True
|
|
|
|
if orm_ops:
|
|
self.line_ids = orm_ops
|
|
|
|
return discount_applied
|
|
|
|
def _lines_check_apply_partial_matching(self):
|
|
"""Attempt partial matching on the most recently added journal item.
|
|
|
|
If multiple items are matched and the last one overshoots the remaining
|
|
balance, reduce it to create a partial reconciliation. Also resets
|
|
any previous partials except on manually modified entries.
|
|
|
|
Returns True if a partial was applied, False otherwise.
|
|
"""
|
|
matched_entries = self.line_ids.filtered(lambda rec: rec.flag == 'new_aml')
|
|
if not matched_entries:
|
|
return False
|
|
|
|
final_entry = matched_entries[-1]
|
|
|
|
# Reset prior partials on unmodified entries
|
|
reset_ops = []
|
|
affected_entries = self.env['bank.rec.widget.line']
|
|
for matched_entry in matched_entries:
|
|
has_partial = matched_entry.display_stroked_amount_currency or matched_entry.display_stroked_balance
|
|
if has_partial and not matched_entry.manually_modified:
|
|
reset_ops.append(Command.update(matched_entry.id, {
|
|
'amount_currency': matched_entry.source_amount_currency,
|
|
'balance': matched_entry.source_balance,
|
|
}))
|
|
affected_entries |= matched_entry
|
|
|
|
if reset_ops:
|
|
self.line_ids = reset_ops
|
|
self._lines_recompute_exchange_diff(affected_entries)
|
|
|
|
# Check if the last entry should be partially matched
|
|
partial_data = self._lines_check_partial_amount(final_entry)
|
|
if partial_data:
|
|
final_entry.amount_currency = partial_data['amount_currency']
|
|
final_entry.balance = partial_data['balance']
|
|
fx_entry = partial_data['exchange_diff_line']
|
|
if fx_entry:
|
|
fx_entry.balance = partial_data['exchange_balance']
|
|
if fx_entry.currency_id == self.company_currency_id:
|
|
fx_entry.amount_currency = fx_entry.balance
|
|
return True
|
|
|
|
return False
|
|
|
|
def _lines_load_new_amls(self, move_lines, reco_model=None):
|
|
"""Create widget entries for a set of journal items to be reconciled."""
|
|
orm_ops = []
|
|
model_ref = {'reconcile_model_id': reco_model.id} if reco_model else {}
|
|
for move_line in move_lines:
|
|
entry_vals = self._lines_prepare_new_aml_line(move_line, **model_ref)
|
|
orm_ops.append(Command.create(entry_vals))
|
|
|
|
if orm_ops:
|
|
self.line_ids = orm_ops
|
|
|
|
# =========================================================================
|
|
# TAX COMPUTATION
|
|
# =========================================================================
|
|
|
|
def _prepare_base_line_for_taxes_computation(self, entry):
|
|
"""Convert a widget entry into the format expected by account.tax computation.
|
|
|
|
Handles both tax-exclusive and tax-inclusive modes based on
|
|
the force_price_included_taxes flag.
|
|
"""
|
|
self.ensure_one()
|
|
applied_taxes = entry.tax_ids
|
|
tax_usage = applied_taxes[0].type_tax_use if applied_taxes else None
|
|
is_refund_context = (
|
|
(tax_usage == 'sale' and entry.balance > 0.0)
|
|
or (tax_usage == 'purchase' and entry.balance < 0.0)
|
|
)
|
|
|
|
if entry.force_price_included_taxes and applied_taxes:
|
|
computation_mode = 'total_included'
|
|
base_value = entry.tax_base_amount_currency
|
|
else:
|
|
computation_mode = 'total_excluded'
|
|
base_value = entry.amount_currency
|
|
|
|
return self.env['account.tax']._prepare_base_line_for_taxes_computation(
|
|
entry,
|
|
price_unit=base_value,
|
|
quantity=1.0,
|
|
is_refund=is_refund_context,
|
|
special_mode=computation_mode,
|
|
)
|
|
|
|
def _prepare_tax_line_for_taxes_computation(self, entry):
|
|
"""Convert a tax widget entry for the tax computation engine."""
|
|
self.ensure_one()
|
|
return self.env['account.tax']._prepare_tax_line_for_taxes_computation(entry)
|
|
|
|
def _lines_prepare_tax_line(self, tax_data):
|
|
"""Build creation values for a tax entry from computed tax data."""
|
|
self.ensure_one()
|
|
tax_rep = self.env['account.tax.repartition.line'].browse(tax_data['tax_repartition_line_id'])
|
|
description = tax_rep.tax_id.name
|
|
if self.st_line_id.payment_ref:
|
|
description = f'{description} - {self.st_line_id.payment_ref}'
|
|
|
|
entry_currency = self.env['res.currency'].browse(tax_data['currency_id'])
|
|
foreign_amount = tax_data['amount_currency']
|
|
comp_amounts = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
|
|
entry_currency, None, foreign_amount,
|
|
)
|
|
|
|
return {
|
|
'flag': 'tax_line',
|
|
'account_id': tax_data['account_id'],
|
|
'date': self.st_line_id.date,
|
|
'name': description,
|
|
'partner_id': tax_data['partner_id'],
|
|
'currency_id': entry_currency.id,
|
|
'amount_currency': foreign_amount,
|
|
'balance': comp_amounts['balance'],
|
|
'analytic_distribution': tax_data['analytic_distribution'],
|
|
'tax_repartition_line_id': tax_rep.id,
|
|
'tax_ids': tax_data['tax_ids'],
|
|
'tax_tag_ids': tax_data['tax_tag_ids'],
|
|
'group_tax_id': tax_data['group_tax_id'],
|
|
}
|
|
|
|
def _lines_recompute_taxes(self):
|
|
"""Recalculate all tax entries based on the current manual base entries.
|
|
|
|
Uses Odoo's tax computation engine to determine the correct tax amounts,
|
|
then updates/creates/deletes tax entries accordingly.
|
|
"""
|
|
self.ensure_one()
|
|
TaxEngine = self.env['account.tax']
|
|
|
|
# Collect base and tax entries
|
|
base_entries = self.line_ids.filtered(
|
|
lambda rec: rec.flag == 'manual' and not rec.tax_repartition_line_id
|
|
)
|
|
tax_entries = self.line_ids.filtered(lambda rec: rec.flag == 'tax_line')
|
|
|
|
base_data = [self._prepare_base_line_for_taxes_computation(rec) for rec in base_entries]
|
|
tax_data = [self._prepare_tax_line_for_taxes_computation(rec) for rec in tax_entries]
|
|
|
|
# Run the tax computation pipeline
|
|
TaxEngine._add_tax_details_in_base_lines(base_data, self.company_id)
|
|
TaxEngine._round_base_lines_tax_details(base_data, self.company_id)
|
|
TaxEngine._add_accounting_data_in_base_lines_tax_details(
|
|
base_data, self.company_id, include_caba_tags=True,
|
|
)
|
|
computed_taxes = TaxEngine._prepare_tax_lines(
|
|
base_data, self.company_id, tax_lines=tax_data,
|
|
)
|
|
|
|
orm_ops = []
|
|
|
|
# Update base entries with new tax tags and amounts
|
|
for base_rec, updates in computed_taxes['base_lines_to_update']:
|
|
rec = base_rec['record']
|
|
new_foreign = updates['amount_currency']
|
|
comp_amounts = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
|
|
rec.currency_id, rec.source_balance, new_foreign,
|
|
)
|
|
orm_ops.append(Command.update(rec.id, {
|
|
'balance': comp_amounts['balance'],
|
|
'amount_currency': new_foreign,
|
|
'tax_tag_ids': updates['tax_tag_ids'],
|
|
}))
|
|
|
|
# Remove obsolete tax entries
|
|
for obsolete_tax in computed_taxes['tax_lines_to_delete']:
|
|
orm_ops.append(Command.unlink(obsolete_tax['record'].id))
|
|
|
|
# Add newly computed tax entries
|
|
for new_tax_data in computed_taxes['tax_lines_to_add']:
|
|
orm_ops.append(Command.create(self._lines_prepare_tax_line(new_tax_data)))
|
|
|
|
# Update existing tax entries with new amounts
|
|
for existing_tax, grouping, updates in computed_taxes['tax_lines_to_update']:
|
|
refreshed_vals = self._lines_prepare_tax_line({**grouping, **updates})
|
|
orm_ops.append(Command.update(existing_tax['record'].id, {
|
|
'amount_currency': refreshed_vals['amount_currency'],
|
|
'balance': refreshed_vals['balance'],
|
|
}))
|
|
|
|
self.line_ids = orm_ops
|
|
|
|
# =========================================================================
|
|
# EXCHANGE DIFFERENCE HANDLING
|
|
# =========================================================================
|
|
|
|
def _get_key_mapping_aml_and_exchange_diff(self, entry):
|
|
"""Return the key used to associate exchange difference entries with their source."""
|
|
if entry.source_aml_id:
|
|
return 'source_aml_id', entry.source_aml_id.id
|
|
return None, None
|
|
|
|
def _reorder_exchange_and_aml_lines(self):
|
|
"""Reorder entries so each exchange difference follows its corresponding match."""
|
|
fx_entries = self.line_ids.filtered(lambda rec: rec.flag == 'exchange_diff')
|
|
source_to_fx = defaultdict(lambda: self.env['bank.rec.widget.line'])
|
|
for fx_entry in fx_entries:
|
|
mapping_key = self._get_key_mapping_aml_and_exchange_diff(fx_entry)
|
|
source_to_fx[mapping_key] |= fx_entry
|
|
|
|
ordered_ids = []
|
|
for entry in self.line_ids:
|
|
if entry in fx_entries:
|
|
continue
|
|
ordered_ids.append(entry.id)
|
|
entry_key = self._get_key_mapping_aml_and_exchange_diff(entry)
|
|
if entry_key in source_to_fx:
|
|
ordered_ids.extend(source_to_fx[entry_key].mapped('id'))
|
|
|
|
self.line_ids = self.env['bank.rec.widget.line'].browse(ordered_ids)
|
|
|
|
def _remove_related_exchange_diff_lines(self, target_entries):
|
|
"""Remove exchange difference entries that are linked to the specified entries."""
|
|
unlink_ops = []
|
|
for target in target_entries:
|
|
if target.flag == 'exchange_diff':
|
|
continue
|
|
ref_field, ref_id = self._get_key_mapping_aml_and_exchange_diff(target)
|
|
if not ref_field:
|
|
continue
|
|
for fx_entry in self.line_ids:
|
|
if fx_entry[ref_field] and fx_entry[ref_field].id == ref_id:
|
|
unlink_ops.append(Command.unlink(fx_entry.id))
|
|
|
|
if unlink_ops:
|
|
self.line_ids = unlink_ops
|
|
|
|
def _lines_get_account_balance_exchange_diff(self, entry_currency, comp_balance, foreign_amount):
|
|
"""Compute the exchange difference amount and determine the target account.
|
|
|
|
Compares the balance at the bank transaction rate vs. the original
|
|
journal item rate to determine the foreign exchange gain/loss.
|
|
|
|
Returns (account, exchange_diff_balance) tuple.
|
|
"""
|
|
# Compute balance using the bank transaction rate
|
|
rate_adjusted = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
|
|
entry_currency, comp_balance, foreign_amount,
|
|
)
|
|
adjusted_balance = rate_adjusted['balance']
|
|
|
|
if entry_currency == self.company_currency_id and self.transaction_currency_id != self.company_currency_id:
|
|
# Reconciliation uses the statement line rate; keep the original balance
|
|
adjusted_balance = comp_balance
|
|
elif entry_currency != self.company_currency_id and self.transaction_currency_id == self.company_currency_id:
|
|
# Convert through the foreign currency to handle rate discrepancies
|
|
adjusted_balance = entry_currency._convert(
|
|
foreign_amount, self.transaction_currency_id,
|
|
self.company_id, self.st_line_id.date,
|
|
)
|
|
|
|
fx_diff = adjusted_balance - comp_balance
|
|
if self.company_currency_id.is_zero(fx_diff):
|
|
return self.env['account.account'], 0.0
|
|
|
|
# Select the appropriate exchange gain/loss account
|
|
if fx_diff > 0.0:
|
|
fx_account = self.company_id.expense_currency_exchange_account_id
|
|
else:
|
|
fx_account = self.company_id.income_currency_exchange_account_id
|
|
|
|
return fx_account, fx_diff
|
|
|
|
def _lines_get_exchange_diff_values(self, entry):
|
|
"""Compute exchange difference entry values for a matched journal item."""
|
|
if entry.flag != 'new_aml':
|
|
return []
|
|
|
|
fx_account, fx_amount = self._lines_get_account_balance_exchange_diff(
|
|
entry.currency_id, entry.balance, entry.amount_currency,
|
|
)
|
|
if entry.currency_id.is_zero(fx_amount):
|
|
return []
|
|
|
|
return [{
|
|
'flag': 'exchange_diff',
|
|
'source_aml_id': entry.source_aml_id.id,
|
|
'account_id': fx_account.id,
|
|
'date': entry.date,
|
|
'name': _("Exchange Difference: %s", entry.name),
|
|
'partner_id': entry.partner_id.id,
|
|
'currency_id': entry.currency_id.id,
|
|
'amount_currency': fx_amount if entry.currency_id == self.company_currency_id else 0.0,
|
|
'balance': fx_amount,
|
|
'source_amount_currency': entry.amount_currency,
|
|
'source_balance': fx_amount,
|
|
}]
|
|
|
|
def _lines_recompute_exchange_diff(self, target_entries):
|
|
"""Recalculate exchange difference entries for the specified matched items.
|
|
|
|
Creates new exchange difference entries or updates existing ones as needed.
|
|
Also cleans up orphaned exchange differences for deleted entries.
|
|
"""
|
|
self.ensure_one()
|
|
# Clean up exchange diffs for entries that were removed
|
|
removed_entries = target_entries - self.line_ids
|
|
self._remove_related_exchange_diff_lines(removed_entries)
|
|
target_entries = target_entries - removed_entries
|
|
|
|
existing_fx = self.line_ids.filtered(
|
|
lambda rec: rec.flag == 'exchange_diff'
|
|
).grouped('source_aml_id')
|
|
|
|
orm_ops = []
|
|
needs_reorder = False
|
|
|
|
for entry in target_entries:
|
|
fx_values_list = self._lines_get_exchange_diff_values(entry)
|
|
if entry.source_aml_id and entry.source_aml_id in existing_fx:
|
|
# Update existing exchange difference entry
|
|
for fx_vals in fx_values_list:
|
|
orm_ops.append(Command.update(existing_fx[entry.source_aml_id].id, fx_vals))
|
|
else:
|
|
# Create new exchange difference entry
|
|
for fx_vals in fx_values_list:
|
|
orm_ops.append(Command.create(fx_vals))
|
|
needs_reorder = True
|
|
|
|
if orm_ops:
|
|
self.line_ids = orm_ops
|
|
if needs_reorder:
|
|
self._reorder_exchange_and_aml_lines()
|
|
|
|
# =========================================================================
|
|
# RECONCILE MODEL WRITE-OFF PREPARATION
|
|
# =========================================================================
|
|
|
|
def _lines_prepare_reco_model_write_off_vals(self, reco_model, writeoff_data):
|
|
"""Build widget entry values from a reconcile model's write-off specification."""
|
|
self.ensure_one()
|
|
comp_amounts = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
|
|
self.transaction_currency_id, None, writeoff_data['amount_currency'],
|
|
)
|
|
return {
|
|
'flag': 'manual',
|
|
'account_id': writeoff_data['account_id'],
|
|
'date': self.st_line_id.date,
|
|
'name': writeoff_data['name'],
|
|
'partner_id': writeoff_data['partner_id'],
|
|
'currency_id': writeoff_data['currency_id'],
|
|
'amount_currency': writeoff_data['amount_currency'],
|
|
'balance': comp_amounts['balance'],
|
|
'tax_base_amount_currency': writeoff_data['amount_currency'],
|
|
'force_price_included_taxes': True,
|
|
'reconcile_model_id': reco_model.id,
|
|
'analytic_distribution': writeoff_data['analytic_distribution'],
|
|
'tax_ids': writeoff_data['tax_ids'],
|
|
}
|
|
|
|
# =========================================================================
|
|
# LINE VALUE CHANGE HANDLERS
|
|
# =========================================================================
|
|
|
|
def _line_value_changed_account_id(self, entry):
|
|
"""Handle account change on a widget entry."""
|
|
self.ensure_one()
|
|
self._lines_turn_auto_balance_into_manual_line(entry)
|
|
if entry.flag not in ('tax_line', 'early_payment') and entry.tax_ids:
|
|
self._lines_recompute_taxes()
|
|
self._lines_add_auto_balance_line()
|
|
|
|
def _line_value_changed_date(self, entry):
|
|
"""Handle date change - propagate to statement line if editing liquidity entry."""
|
|
self.ensure_one()
|
|
if entry.flag == 'liquidity' and entry.date:
|
|
self.st_line_id.date = entry.date
|
|
self._action_reload_liquidity_line()
|
|
self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
|
|
|
|
def _line_value_changed_ref(self, entry):
|
|
"""Handle reference change on the liquidity entry."""
|
|
self.ensure_one()
|
|
if entry.flag == 'liquidity':
|
|
self.st_line_id.move_id.ref = entry.ref
|
|
self._action_reload_liquidity_line()
|
|
self.return_todo_command = {'reset_record': True}
|
|
|
|
def _line_value_changed_narration(self, entry):
|
|
"""Handle narration change on the liquidity entry."""
|
|
self.ensure_one()
|
|
if entry.flag == 'liquidity':
|
|
self.st_line_id.move_id.narration = entry.narration
|
|
self._action_reload_liquidity_line()
|
|
self.return_todo_command = {'reset_record': True}
|
|
|
|
def _line_value_changed_name(self, entry):
|
|
"""Handle label/name change - propagate to statement line if liquidity."""
|
|
self.ensure_one()
|
|
if entry.flag == 'liquidity':
|
|
self.st_line_id.payment_ref = entry.name
|
|
self._action_reload_liquidity_line()
|
|
self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
|
|
return
|
|
self._lines_turn_auto_balance_into_manual_line(entry)
|
|
|
|
def _line_value_changed_amount_transaction_currency(self, entry):
|
|
"""Handle transaction currency amount change on the liquidity entry."""
|
|
self.ensure_one()
|
|
if entry.flag != 'liquidity':
|
|
return
|
|
if entry.transaction_currency_id != self.journal_currency_id:
|
|
self.st_line_id.amount_currency = entry.amount_transaction_currency
|
|
self.st_line_id.foreign_currency_id = entry.transaction_currency_id
|
|
else:
|
|
self.st_line_id.amount_currency = 0.0
|
|
self.st_line_id.foreign_currency_id = None
|
|
self._action_reload_liquidity_line()
|
|
self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
|
|
|
|
def _line_value_changed_transaction_currency_id(self, entry):
|
|
"""Handle transaction currency change."""
|
|
self._line_value_changed_amount_transaction_currency(entry)
|
|
|
|
def _line_value_changed_amount_currency(self, entry):
|
|
"""Handle foreign currency amount change on any entry.
|
|
|
|
For liquidity entries, propagates to the statement line. For matched
|
|
entries (new_aml), enforces bounds and adjusts the company-currency
|
|
balance using the appropriate rate. For manual entries, converts using
|
|
the statement line or market rate.
|
|
"""
|
|
self.ensure_one()
|
|
if entry.flag == 'liquidity':
|
|
self.st_line_id.amount = entry.amount_currency
|
|
self._action_reload_liquidity_line()
|
|
self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
|
|
return
|
|
|
|
self._lines_turn_auto_balance_into_manual_line(entry)
|
|
|
|
direction = -1 if entry.amount_currency < 0.0 else 1
|
|
if entry.flag == 'new_aml':
|
|
# Clamp to valid range: same sign as source, not exceeding source
|
|
clamped = direction * max(0.0, min(abs(entry.amount_currency), abs(entry.source_amount_currency)))
|
|
entry.amount_currency = clamped
|
|
entry.manually_modified = True
|
|
# Reset to full amount if user clears the field
|
|
if not entry.amount_currency:
|
|
entry.amount_currency = entry.source_amount_currency
|
|
elif not entry.amount_currency:
|
|
entry.amount_currency = 0.0
|
|
|
|
# Compute the corresponding company-currency balance
|
|
if entry.currency_id == entry.company_currency_id:
|
|
entry.balance = entry.amount_currency
|
|
elif entry.flag == 'new_aml':
|
|
if entry.currency_id.compare_amounts(
|
|
abs(entry.amount_currency), abs(entry.source_amount_currency)
|
|
) == 0.0:
|
|
entry.balance = entry.source_balance
|
|
elif entry.source_balance:
|
|
source_rate = abs(entry.source_amount_currency / entry.source_balance)
|
|
entry.balance = entry.company_currency_id.round(entry.amount_currency / source_rate)
|
|
else:
|
|
entry.balance = 0.0
|
|
elif entry.flag in ('manual', 'early_payment', 'tax_line'):
|
|
if entry.currency_id in (self.transaction_currency_id, self.journal_currency_id):
|
|
entry.balance = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
|
|
entry.currency_id, None, entry.amount_currency,
|
|
)['balance']
|
|
else:
|
|
entry.balance = entry.currency_id._convert(
|
|
entry.amount_currency, self.company_currency_id,
|
|
self.company_id, self.st_line_id.date,
|
|
)
|
|
|
|
if entry.flag not in ('tax_line', 'early_payment'):
|
|
if entry.tax_ids:
|
|
entry.force_price_included_taxes = False
|
|
self._lines_recompute_taxes()
|
|
self._lines_recompute_exchange_diff(entry)
|
|
|
|
self._lines_add_auto_balance_line()
|
|
|
|
def _line_value_changed_balance(self, entry):
|
|
"""Handle company-currency balance change on any entry.
|
|
|
|
Similar to amount_currency changes but operates in company currency.
|
|
For matched entries, enforces the same clamping rules.
|
|
"""
|
|
self.ensure_one()
|
|
if entry.flag == 'liquidity':
|
|
self.st_line_id.amount = entry.balance
|
|
self._action_reload_liquidity_line()
|
|
self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
|
|
return
|
|
|
|
self._lines_turn_auto_balance_into_manual_line(entry)
|
|
|
|
direction = -1 if entry.balance < 0.0 else 1
|
|
if entry.flag == 'new_aml':
|
|
clamped = direction * max(0.0, min(abs(entry.balance), abs(entry.source_balance)))
|
|
entry.balance = clamped
|
|
entry.manually_modified = True
|
|
if not entry.balance:
|
|
entry.balance = entry.source_balance
|
|
elif not entry.balance:
|
|
entry.balance = 0.0
|
|
|
|
if entry.currency_id == entry.company_currency_id:
|
|
entry.amount_currency = entry.balance
|
|
self._line_value_changed_amount_currency(entry)
|
|
elif entry.flag == 'exchange_diff':
|
|
self._lines_add_auto_balance_line()
|
|
else:
|
|
self._lines_recompute_exchange_diff(entry)
|
|
self._lines_add_auto_balance_line()
|
|
|
|
def _line_value_changed_currency_id(self, entry):
|
|
"""Handle currency change - triggers amount recomputation."""
|
|
self.ensure_one()
|
|
self._line_value_changed_amount_currency(entry)
|
|
|
|
def _line_value_changed_tax_ids(self, entry):
|
|
"""Handle tax selection change on a manual entry.
|
|
|
|
When taxes are added, enables tax-inclusive mode. When taxes are
|
|
removed, restores the original base amount if it was in inclusive mode.
|
|
"""
|
|
self.ensure_one()
|
|
self._lines_turn_auto_balance_into_manual_line(entry)
|
|
|
|
if entry.tax_ids:
|
|
if not entry.tax_base_amount_currency:
|
|
entry.tax_base_amount_currency = entry.amount_currency
|
|
entry.force_price_included_taxes = True
|
|
else:
|
|
if entry.force_price_included_taxes:
|
|
entry.amount_currency = entry.tax_base_amount_currency
|
|
self._line_value_changed_amount_currency(entry)
|
|
entry.tax_base_amount_currency = False
|
|
|
|
self._lines_recompute_taxes()
|
|
self._lines_add_auto_balance_line()
|
|
|
|
def _line_value_changed_partner_id(self, entry):
|
|
"""Handle partner change on an entry.
|
|
|
|
For liquidity entries, propagates to the statement line. For other
|
|
entries, attempts to set the appropriate receivable/payable account
|
|
based on the partner's configuration and outstanding balances.
|
|
"""
|
|
self.ensure_one()
|
|
if entry.flag == 'liquidity':
|
|
self.st_line_id.partner_id = entry.partner_id
|
|
self._action_reload_liquidity_line()
|
|
self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
|
|
return
|
|
|
|
self._lines_turn_auto_balance_into_manual_line(entry)
|
|
|
|
suggested_account = None
|
|
if entry.partner_id:
|
|
is_customer_only = entry.partner_id.customer_rank and not entry.partner_id.supplier_rank
|
|
is_vendor_only = entry.partner_id.supplier_rank and not entry.partner_id.customer_rank
|
|
recv_balance_zero = entry.partner_currency_id.is_zero(entry.partner_receivable_amount)
|
|
pay_balance_zero = entry.partner_currency_id.is_zero(entry.partner_payable_amount)
|
|
|
|
if is_customer_only or (not recv_balance_zero and pay_balance_zero):
|
|
suggested_account = entry.partner_receivable_account_id
|
|
elif is_vendor_only or (recv_balance_zero and not pay_balance_zero):
|
|
suggested_account = entry.partner_payable_account_id
|
|
elif self.st_line_id.amount < 0.0:
|
|
suggested_account = entry.partner_payable_account_id or entry.partner_receivable_account_id
|
|
else:
|
|
suggested_account = entry.partner_receivable_account_id or entry.partner_payable_account_id
|
|
|
|
if suggested_account:
|
|
entry.account_id = suggested_account
|
|
self._line_value_changed_account_id(entry)
|
|
elif entry.flag not in ('tax_line', 'early_payment') and entry.tax_ids:
|
|
self._lines_recompute_taxes()
|
|
self._lines_add_auto_balance_line()
|
|
|
|
def _line_value_changed_analytic_distribution(self, entry):
|
|
"""Handle analytic distribution change - recompute taxes if analytics affect them."""
|
|
self.ensure_one()
|
|
self._lines_turn_auto_balance_into_manual_line(entry)
|
|
if entry.flag not in ('tax_line', 'early_payment') and any(t.analytic for t in entry.tax_ids):
|
|
self._lines_recompute_taxes()
|
|
self._lines_add_auto_balance_line()
|
|
|
|
# =========================================================================
|
|
# CORE ACTIONS
|
|
# =========================================================================
|
|
|
|
def _action_trigger_matching_rules(self):
|
|
"""Run automatic reconciliation rules against the current statement line.
|
|
|
|
Searches for applicable reconcile models and applies the first match,
|
|
which may add journal items, apply a write-off model, or flag for
|
|
auto-reconciliation.
|
|
"""
|
|
self.ensure_one()
|
|
if self.st_line_id.is_reconciled:
|
|
return
|
|
|
|
applicable_rules = self.env['account.reconcile.model'].search([
|
|
('rule_type', '!=', 'writeoff_button'),
|
|
('company_id', '=', self.company_id.id),
|
|
'|',
|
|
('match_journal_ids', '=', False),
|
|
('match_journal_ids', '=', self.st_line_id.journal_id.id),
|
|
])
|
|
match_result = applicable_rules._apply_rules(self.st_line_id, self.partner_id)
|
|
|
|
if match_result.get('amls'):
|
|
matched_model = match_result['model']
|
|
permit_partial = match_result.get('status') != 'write_off'
|
|
self._action_add_new_amls(
|
|
match_result['amls'],
|
|
reco_model=matched_model,
|
|
allow_partial=permit_partial,
|
|
)
|
|
if match_result.get('status') == 'write_off':
|
|
self._action_select_reconcile_model(match_result['model'])
|
|
if match_result.get('auto_reconcile'):
|
|
self.matching_rules_allow_auto_reconcile = True
|
|
|
|
return match_result
|
|
|
|
def _prepare_embedded_views_data(self):
|
|
"""Build configuration for the embedded journal item list views.
|
|
|
|
Returns domain, filters, and context for the JS frontend to render
|
|
the list of available journal items for matching.
|
|
"""
|
|
self.ensure_one()
|
|
stmt_entry = self.st_line_id
|
|
|
|
view_context = {
|
|
'search_view_ref': 'fusion_accounting.view_account_move_line_search_bank_rec_widget',
|
|
'list_view_ref': 'fusion_accounting.view_account_move_line_list_bank_rec_widget',
|
|
}
|
|
if self.partner_id:
|
|
view_context['search_default_partner_id'] = self.partner_id.id
|
|
|
|
# Build dynamic filter for Customer/Vendor vs Misc separation
|
|
journal = stmt_entry.journal_id
|
|
payment_account_ids = set()
|
|
|
|
for acct in journal._get_journal_inbound_outstanding_payment_accounts() - journal.default_account_id:
|
|
payment_account_ids.add(acct.id)
|
|
for acct in journal._get_journal_outbound_outstanding_payment_accounts() - journal.default_account_id:
|
|
payment_account_ids.add(acct.id)
|
|
|
|
receivable_payable_domain = [
|
|
'|',
|
|
'&',
|
|
('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')),
|
|
('payment_id', '=', False),
|
|
'&',
|
|
('account_id', 'in', tuple(payment_account_ids)),
|
|
('payment_id', '!=', False),
|
|
]
|
|
|
|
filter_specs = [
|
|
{
|
|
'name': 'receivable_payable_matching',
|
|
'description': _("Customer/Vendor"),
|
|
'domain': str(receivable_payable_domain),
|
|
'no_separator': True,
|
|
'is_default': False,
|
|
},
|
|
{
|
|
'name': 'misc_matching',
|
|
'description': _("Misc"),
|
|
'domain': str(['!'] + receivable_payable_domain),
|
|
'is_default': False,
|
|
},
|
|
]
|
|
|
|
return {
|
|
'amls': {
|
|
'domain': stmt_entry._get_default_amls_matching_domain(),
|
|
'dynamic_filters': filter_specs,
|
|
'context': view_context,
|
|
},
|
|
}
|
|
|
|
def _action_mount_st_line(self, stmt_entry):
|
|
"""Load a statement line into the widget and trigger matching rules."""
|
|
self.ensure_one()
|
|
self.st_line_id = stmt_entry
|
|
self.form_index = self.line_ids[0].index if self.state == 'reconciled' else None
|
|
self._action_trigger_matching_rules()
|
|
|
|
def _action_reload_liquidity_line(self):
|
|
"""Reload the widget after the liquidity entry (statement line) was modified."""
|
|
self.ensure_one()
|
|
self = self.with_context(default_st_line_id=self.st_line_id.id)
|
|
self.invalidate_model()
|
|
|
|
# Force-load lines to prevent cache issues
|
|
self.line_ids
|
|
self._action_trigger_matching_rules()
|
|
|
|
# Restore focus to the liquidity entry
|
|
liq_entry = self.line_ids.filtered(lambda rec: rec.flag == 'liquidity')
|
|
self._js_action_mount_line_in_edit(liq_entry.index)
|
|
|
|
def _action_clear_manual_operations_form(self):
|
|
"""Close the manual operations form panel."""
|
|
self.form_index = None
|
|
|
|
def _action_remove_lines(self, target_entries):
|
|
"""Remove the specified entries and rebalance.
|
|
|
|
After removal, recomputes taxes if needed, checks for early payment
|
|
discounts or partial matching opportunities, and refreshes the
|
|
auto-balance entry.
|
|
"""
|
|
self.ensure_one()
|
|
if not target_entries:
|
|
return
|
|
|
|
taxes_affected = bool(target_entries.tax_ids)
|
|
had_matched_items = any(entry.flag == 'new_aml' for entry in target_entries)
|
|
|
|
self.line_ids = [Command.unlink(entry.id) for entry in target_entries]
|
|
self._remove_related_exchange_diff_lines(target_entries)
|
|
|
|
if taxes_affected:
|
|
self._lines_recompute_taxes()
|
|
if had_matched_items and not self._lines_check_apply_early_payment_discount():
|
|
self._lines_check_apply_partial_matching()
|
|
self._lines_add_auto_balance_line()
|
|
self._action_clear_manual_operations_form()
|
|
|
|
def _action_add_new_amls(self, move_lines, reco_model=None, allow_partial=True):
|
|
"""Add journal items as reconciliation counterparts.
|
|
|
|
Filters out items that are already present, creates widget entries,
|
|
computes exchange differences, checks for early payment discounts
|
|
and partial matching, then rebalances.
|
|
"""
|
|
self.ensure_one()
|
|
already_loaded = set(
|
|
self.line_ids
|
|
.filtered(lambda rec: rec.flag in ('new_aml', 'aml', 'liquidity'))
|
|
.source_aml_id
|
|
)
|
|
new_items = move_lines.filtered(lambda item: item not in already_loaded)
|
|
if not new_items:
|
|
return
|
|
|
|
self._lines_load_new_amls(new_items, reco_model=reco_model)
|
|
newly_added = self.line_ids.filtered(
|
|
lambda rec: rec.flag == 'new_aml' and rec.source_aml_id in new_items
|
|
)
|
|
self._lines_recompute_exchange_diff(newly_added)
|
|
|
|
if not self._lines_check_apply_early_payment_discount() and allow_partial:
|
|
self._lines_check_apply_partial_matching()
|
|
|
|
self._lines_add_auto_balance_line()
|
|
self._action_clear_manual_operations_form()
|
|
|
|
def _action_remove_new_amls(self, move_lines):
|
|
"""Remove specific matched journal items from the reconciliation."""
|
|
self.ensure_one()
|
|
entries_to_remove = self.line_ids.filtered(
|
|
lambda rec: rec.flag == 'new_aml' and rec.source_aml_id in move_lines
|
|
)
|
|
self._action_remove_lines(entries_to_remove)
|
|
|
|
def _action_select_reconcile_model(self, reco_model):
|
|
"""Apply a reconciliation model's write-off lines.
|
|
|
|
Removes entries from any previously selected model, then creates
|
|
new entries based on the selected model's configuration. For
|
|
sale/purchase models, creates an invoice/bill instead.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# Remove entries from previously applied models
|
|
self.line_ids = [
|
|
Command.unlink(entry.id)
|
|
for entry in self.line_ids
|
|
if (
|
|
entry.flag not in ('new_aml', 'liquidity')
|
|
and entry.reconcile_model_id
|
|
and entry.reconcile_model_id != reco_model
|
|
)
|
|
]
|
|
self._lines_recompute_taxes()
|
|
|
|
if reco_model.to_check:
|
|
self.st_line_id.move_id.checked = False
|
|
self.invalidate_recordset(fnames=['st_line_checked'])
|
|
|
|
# Compute available balance for the model's write-off lines
|
|
balance_data = self._lines_prepare_auto_balance_line()
|
|
available_amount = balance_data['amount_currency']
|
|
|
|
writeoff_specs = reco_model._apply_lines_for_bank_widget(
|
|
available_amount, self.partner_id, self.st_line_id,
|
|
)
|
|
|
|
if reco_model.rule_type == 'writeoff_button' and reco_model.counterpart_type in ('sale', 'purchase'):
|
|
# Create an invoice/bill from the write-off specification
|
|
created_doc = self._create_invoice_from_write_off_values(reco_model, writeoff_specs)
|
|
action_data = {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.move',
|
|
'context': {'create': False},
|
|
'view_mode': 'form',
|
|
'res_id': created_doc.id,
|
|
}
|
|
self.return_todo_command = clean_action(action_data, self.env)
|
|
else:
|
|
# Create write-off entries directly
|
|
self.line_ids = [
|
|
Command.create(self._lines_prepare_reco_model_write_off_vals(reco_model, spec))
|
|
for spec in writeoff_specs
|
|
]
|
|
self._lines_recompute_taxes()
|
|
self._lines_add_auto_balance_line()
|
|
|
|
def _create_invoice_from_write_off_values(self, reco_model, writeoff_specs):
|
|
"""Create an invoice or bill from reconcile model write-off data.
|
|
|
|
Determines the move type based on the amount direction and model type,
|
|
then creates the invoice with appropriate line items.
|
|
"""
|
|
target_journal = reco_model.line_ids.journal_id[:1]
|
|
invoice_items = []
|
|
cumulative_amount = 0.0
|
|
pct_of_statement = 0.0
|
|
|
|
for spec in writeoff_specs:
|
|
spec_copy = dict(spec)
|
|
if 'percentage_st_line' not in spec_copy:
|
|
cumulative_amount -= spec_copy['amount_currency']
|
|
pct_of_statement += spec_copy.pop('percentage_st_line', 0)
|
|
spec_copy.pop('currency_id', None)
|
|
spec_copy.pop('partner_id', None)
|
|
spec_copy.pop('reconcile_model_id', None)
|
|
invoice_items.append(spec_copy)
|
|
|
|
stmt_amount = (
|
|
self.st_line_id.amount_currency
|
|
if self.st_line_id.foreign_currency_id
|
|
else self.st_line_id.amount
|
|
)
|
|
cumulative_amount += self.transaction_currency_id.round(stmt_amount * pct_of_statement)
|
|
|
|
# Determine invoice type from amount direction and model type
|
|
if reco_model.counterpart_type == 'sale':
|
|
doc_type = 'out_invoice' if cumulative_amount > 0 else 'out_refund'
|
|
else:
|
|
doc_type = 'in_invoice' if cumulative_amount < 0 else 'in_refund'
|
|
|
|
sign_for_price = 1 if cumulative_amount < 0.0 else -1
|
|
item_commands = []
|
|
for item_vals in invoice_items:
|
|
raw_total = sign_for_price * item_vals.pop('amount_currency')
|
|
applicable_taxes = self.env['account.tax'].browse(item_vals['tax_ids'][0][2])
|
|
item_vals['price_unit'] = self._get_invoice_price_unit_from_price_total(
|
|
raw_total, applicable_taxes,
|
|
)
|
|
item_commands.append(Command.create(item_vals))
|
|
|
|
doc_vals = {
|
|
'invoice_date': self.st_line_id.date,
|
|
'move_type': doc_type,
|
|
'partner_id': self.st_line_id.partner_id.id,
|
|
'currency_id': self.transaction_currency_id.id,
|
|
'payment_reference': self.st_line_id.payment_ref,
|
|
'invoice_line_ids': item_commands,
|
|
}
|
|
if target_journal:
|
|
doc_vals['journal_id'] = target_journal.id
|
|
|
|
created_doc = self.env['account.move'].create(doc_vals)
|
|
if not created_doc.currency_id.is_zero(created_doc.amount_total - cumulative_amount):
|
|
created_doc._check_total_amount(abs(cumulative_amount))
|
|
|
|
return created_doc
|
|
|
|
def _get_invoice_price_unit_from_price_total(self, total_with_tax, applicable_taxes):
|
|
"""Reverse-compute the unit price from a tax-inclusive total."""
|
|
self.ensure_one()
|
|
tax_details = applicable_taxes._get_tax_details(
|
|
total_with_tax,
|
|
1.0,
|
|
precision_rounding=self.transaction_currency_id.rounding,
|
|
rounding_method=self.company_id.tax_calculation_rounding_method,
|
|
special_mode='total_included',
|
|
)
|
|
included_tax_total = sum(
|
|
detail['tax_amount']
|
|
for detail in tax_details['taxes_data']
|
|
if detail['tax'].price_include
|
|
)
|
|
return tax_details['total_excluded'] + included_tax_total
|
|
|
|
# =========================================================================
|
|
# VALIDATION
|
|
# =========================================================================
|
|
|
|
def _validation_lines_vals(self, orm_ops, fx_correction_data, reconciliation_pairs):
|
|
"""Build journal item creation commands from the current widget entries.
|
|
|
|
Processes each widget entry into an account.move.line creation command,
|
|
squashing exchange difference amounts into their corresponding matched
|
|
items. Tracks which entries need reconciliation against counterparts.
|
|
"""
|
|
non_liq_entries = self.line_ids.filtered(lambda rec: rec.flag != 'liquidity')
|
|
unique_partners = non_liq_entries.partner_id
|
|
partner_for_liq = unique_partners if len(unique_partners) == 1 else self.env['res.partner']
|
|
|
|
fx_by_source = self.line_ids.filtered(
|
|
lambda rec: rec.flag == 'exchange_diff'
|
|
).grouped('source_aml_id')
|
|
|
|
for entry in self.line_ids:
|
|
if entry.flag == 'exchange_diff':
|
|
continue
|
|
|
|
entry_foreign = entry.amount_currency
|
|
entry_comp = entry.balance
|
|
|
|
if entry.flag == 'new_aml':
|
|
sequence_idx = len(orm_ops) + 1
|
|
reconciliation_pairs.append((sequence_idx, entry.source_aml_id))
|
|
|
|
related_fx = fx_by_source.get(entry.source_aml_id)
|
|
if related_fx:
|
|
fx_correction_data[sequence_idx] = {
|
|
'amount_residual': related_fx.balance,
|
|
'amount_residual_currency': related_fx.amount_currency,
|
|
'analytic_distribution': related_fx.analytic_distribution,
|
|
}
|
|
entry_foreign += related_fx.amount_currency
|
|
entry_comp += related_fx.balance
|
|
|
|
# Determine partner: use unified partner for liquidity/auto_balance
|
|
assigned_partner = (
|
|
partner_for_liq.id
|
|
if entry.flag in ('liquidity', 'auto_balance')
|
|
else entry.partner_id.id
|
|
)
|
|
|
|
orm_ops.append(Command.create(entry._get_aml_values(
|
|
sequence=len(orm_ops) + 1,
|
|
partner_id=assigned_partner,
|
|
amount_currency=entry_foreign,
|
|
balance=entry_comp,
|
|
)))
|
|
|
|
def _action_validate(self):
|
|
"""Finalize the reconciliation by writing journal items and reconciling.
|
|
|
|
Creates the final set of journal items on the statement line's move,
|
|
handles exchange difference moves, performs the reconciliation, and
|
|
updates partner/bank account information.
|
|
"""
|
|
self.ensure_one()
|
|
non_liq_entries = self.line_ids.filtered(lambda rec: rec.flag != 'liquidity')
|
|
unique_partners = non_liq_entries.partner_id
|
|
partner_for_move = unique_partners if len(unique_partners) == 1 else self.env['res.partner']
|
|
|
|
reconciliation_pairs = []
|
|
orm_ops = []
|
|
fx_correction_data = {}
|
|
|
|
self._validation_lines_vals(orm_ops, fx_correction_data, reconciliation_pairs)
|
|
|
|
stmt_entry = self.st_line_id
|
|
target_move = stmt_entry.move_id
|
|
|
|
# Write the finalized journal items to the move
|
|
editable_move = target_move.with_context(force_delete=True, skip_readonly_check=True)
|
|
editable_move.write({
|
|
'partner_id': partner_for_move.id,
|
|
'line_ids': [Command.clear()] + orm_ops,
|
|
})
|
|
|
|
# Map sequences to the created journal items
|
|
MoveLine = self.env['account.move.line']
|
|
items_by_seq = editable_move.line_ids.grouped('sequence')
|
|
paired_items = [
|
|
(items_by_seq[seq_idx], counterpart_item)
|
|
for seq_idx, counterpart_item in reconciliation_pairs
|
|
]
|
|
all_involved_ids = tuple({
|
|
item_id
|
|
for created_item, counterpart in paired_items
|
|
for item_id in (created_item + counterpart).ids
|
|
})
|
|
|
|
# Handle exchange difference moves
|
|
fx_moves = None
|
|
items_with_fx = MoveLine
|
|
if fx_correction_data:
|
|
fx_move_specs = []
|
|
for created_item, counterpart in paired_items:
|
|
prefetched_item = created_item.with_prefetch(all_involved_ids)
|
|
prefetched_counterpart = counterpart.with_prefetch(all_involved_ids)
|
|
fx_amounts = fx_correction_data.get(prefetched_item.sequence, {})
|
|
fx_analytics = fx_amounts.pop('analytic_distribution', False)
|
|
if fx_amounts:
|
|
# Determine which side gets the exchange difference
|
|
if fx_amounts['amount_residual'] * prefetched_item.amount_residual > 0:
|
|
fx_target = prefetched_item
|
|
else:
|
|
fx_target = prefetched_counterpart
|
|
fx_move_specs.append(fx_target._prepare_exchange_difference_move_vals(
|
|
[fx_amounts],
|
|
exchange_date=max(prefetched_item.date, prefetched_counterpart.date),
|
|
exchange_analytic_distribution=fx_analytics,
|
|
))
|
|
items_with_fx += prefetched_item
|
|
fx_moves = MoveLine._create_exchange_difference_moves(fx_move_specs)
|
|
|
|
# Execute the reconciliation plan
|
|
self.env['account.move.line'].with_context(no_exchange_difference=True)._reconcile_plan([
|
|
(created_item + counterpart).with_prefetch(all_involved_ids)
|
|
for created_item, counterpart in paired_items
|
|
])
|
|
|
|
# Link exchange moves to the appropriate partial records
|
|
for idx, fx_item in enumerate(items_with_fx):
|
|
fx_move = fx_moves[idx]
|
|
for side in ('debit', 'credit'):
|
|
partial_records = fx_item[f'matched_{side}_ids'].filtered(
|
|
lambda partial: partial[f'{side}_move_id'].move_id != fx_move
|
|
)
|
|
partial_records.exchange_move_id = fx_move
|
|
|
|
# Update partner on the statement line
|
|
editable_stmt = stmt_entry.with_context(
|
|
skip_account_move_synchronization=True,
|
|
skip_readonly_check=True,
|
|
)
|
|
editable_stmt.partner_id = partner_for_move
|
|
|
|
# Create or link partner bank account if applicable
|
|
if stmt_entry.account_number and stmt_entry.partner_id:
|
|
editable_stmt.partner_bank_id = (
|
|
stmt_entry._find_or_create_bank_account() or stmt_entry.partner_bank_id
|
|
)
|
|
|
|
# Refresh analytic tracking
|
|
target_move.line_ids.analytic_line_ids.unlink()
|
|
target_move.line_ids.with_context(validate_analytic=True)._create_analytic_lines()
|
|
|
|
@contextmanager
|
|
def _action_validate_method(self):
|
|
"""Context manager wrapping validation to handle post-validation cleanup.
|
|
|
|
Saves a reference to the statement line before validation (which
|
|
invalidates the current record), then reloads everything after.
|
|
"""
|
|
self.ensure_one()
|
|
preserved_stmt = self.st_line_id
|
|
yield
|
|
self.st_line_id = preserved_stmt
|
|
self._ensure_loaded_lines()
|
|
self.return_todo_command = {'done': True}
|
|
|
|
def _action_to_check(self):
|
|
"""Validate and mark the transaction as needing review."""
|
|
self.st_line_id.move_id.checked = False
|
|
self.invalidate_recordset(fnames=['st_line_checked'])
|
|
self._action_validate()
|
|
|
|
# =========================================================================
|
|
# JS ACTION HANDLERS
|
|
# =========================================================================
|
|
|
|
def _js_action_mount_st_line(self, st_line_id):
|
|
"""Load a statement line by ID and return embedded view configuration."""
|
|
self.ensure_one()
|
|
stmt_entry = self.env['account.bank.statement.line'].browse(st_line_id)
|
|
self._action_mount_st_line(stmt_entry)
|
|
self.return_todo_command = self._prepare_embedded_views_data()
|
|
|
|
def _js_action_restore_st_line_data(self, initial_data):
|
|
"""Restore the widget to a previously saved state.
|
|
|
|
Used when the user navigates back from an invoice form or other
|
|
view. Checks if the liquidity entry was modified externally and
|
|
re-triggers matching if so.
|
|
"""
|
|
self.ensure_one()
|
|
saved_values = initial_data['initial_values']
|
|
|
|
self.st_line_id = self.env['account.bank.statement.line'].browse(saved_values['st_line_id'])
|
|
saved_return_cmd = saved_values['return_todo_command']
|
|
|
|
# Detect liquidity line modifications requiring a full reload
|
|
current_liq = self.line_ids.filtered(lambda rec: rec.flag == 'liquidity')
|
|
saved_liq_data = next(
|
|
(cmd[2] for cmd in saved_values['line_ids'] if cmd[2]['flag'] == 'liquidity'),
|
|
{},
|
|
)
|
|
reference_liq = self.env['bank.rec.widget.line'].new(saved_liq_data)
|
|
check_fields = saved_liq_data.keys() - {'index', 'suggestion_html'}
|
|
for field_name in check_fields:
|
|
if reference_liq[field_name] != current_liq[field_name]:
|
|
self._js_action_mount_st_line(self.st_line_id.id)
|
|
return
|
|
|
|
# Remove fields that should be recomputed fresh
|
|
for transient_field in ('id', 'st_line_id', 'todo_command', 'return_todo_command', 'available_reco_model_ids'):
|
|
saved_values.pop(transient_field, None)
|
|
|
|
matching_domain = self.st_line_id._get_default_amls_matching_domain()
|
|
saved_values['line_ids'] = self._process_restore_lines_ids(saved_values['line_ids'])
|
|
self.update(saved_values)
|
|
|
|
# Check if a newly created invoice should be auto-matched
|
|
if (
|
|
saved_return_cmd
|
|
and saved_return_cmd.get('res_model') == 'account.move'
|
|
and (new_doc := self.env['account.move'].browse(saved_return_cmd['res_id']))
|
|
and new_doc.state == 'posted'
|
|
):
|
|
matchable_items = new_doc.line_ids.filtered_domain(matching_domain)
|
|
self._action_add_new_amls(matchable_items)
|
|
else:
|
|
self._lines_add_auto_balance_line()
|
|
|
|
self.return_todo_command = self._prepare_embedded_views_data()
|
|
|
|
def _process_restore_lines_ids(self, saved_commands):
|
|
"""Filter saved line commands to remove entries whose source items are no longer available."""
|
|
matching_domain = self.st_line_id._get_default_amls_matching_domain()
|
|
valid_source_ids = self.env['account.move.line'].browse(
|
|
cmd[2]['source_aml_id']
|
|
for cmd in saved_commands
|
|
if cmd[0] == Command.CREATE and cmd[2].get('source_aml_id')
|
|
).filtered_domain(matching_domain).ids
|
|
valid_source_ids += [None] # Allow entries without a source
|
|
|
|
restored_commands = [Command.clear()]
|
|
for cmd in saved_commands:
|
|
match cmd:
|
|
case (Command.CREATE, _, vals) if vals.get('source_aml_id') in valid_source_ids:
|
|
restored_commands.append(Command.create(vals))
|
|
case _:
|
|
restored_commands.append(cmd)
|
|
return restored_commands
|
|
|
|
def _js_action_validate(self):
|
|
"""JS entry point for validation."""
|
|
with self._action_validate_method():
|
|
self._action_validate()
|
|
|
|
def _js_action_to_check(self):
|
|
"""JS entry point for validate-and-flag-for-review."""
|
|
self.ensure_one()
|
|
if self.state == 'valid':
|
|
with self._action_validate_method():
|
|
self._action_to_check()
|
|
else:
|
|
self.st_line_id.move_id.checked = False
|
|
self.invalidate_recordset(fnames=['st_line_checked'])
|
|
self.return_todo_command = {'done': True}
|
|
|
|
def _js_action_reset(self):
|
|
"""Undo a completed reconciliation and return to matching mode.
|
|
|
|
Validates that the transaction isn't locked by hash verification
|
|
before proceeding with the un-reconciliation.
|
|
"""
|
|
self.ensure_one()
|
|
stmt_entry = self.st_line_id
|
|
|
|
if stmt_entry.inalterable_hash:
|
|
if not stmt_entry.has_reconciled_entries:
|
|
raise UserError(_(
|
|
"You can't hit the reset button on a secured bank transaction."
|
|
))
|
|
else:
|
|
raise RedirectWarning(
|
|
message=_(
|
|
"This bank transaction is protected by an integrity hash and"
|
|
" cannot be reset directly. Would you like to unreconcile it instead?"
|
|
),
|
|
action=stmt_entry.move_id.open_reconcile_view(),
|
|
button_text=_('View Reconciled Entries'),
|
|
)
|
|
|
|
stmt_entry.action_undo_reconciliation()
|
|
self.st_line_id = stmt_entry
|
|
self._ensure_loaded_lines()
|
|
self._action_trigger_matching_rules()
|
|
self.return_todo_command = {'done': True}
|
|
|
|
def _js_action_set_as_checked(self):
|
|
"""Mark the transaction as reviewed/checked."""
|
|
self.ensure_one()
|
|
self.st_line_id.move_id.checked = True
|
|
self.invalidate_recordset(fnames=['st_line_checked'])
|
|
self.return_todo_command = {'done': True}
|
|
|
|
def _js_action_remove_line(self, line_index):
|
|
"""Remove a specific widget entry by its index."""
|
|
self.ensure_one()
|
|
target = self.line_ids.filtered(lambda rec: rec.index == line_index)
|
|
self._action_remove_lines(target)
|
|
|
|
def _js_action_add_new_aml(self, aml_id):
|
|
"""Add a single journal item as a reconciliation counterpart."""
|
|
self.ensure_one()
|
|
move_line = self.env['account.move.line'].browse(aml_id)
|
|
self._action_add_new_amls(move_line)
|
|
|
|
def _js_action_remove_new_aml(self, aml_id):
|
|
"""Remove a specific matched journal item."""
|
|
self.ensure_one()
|
|
move_line = self.env['account.move.line'].browse(aml_id)
|
|
self._action_remove_new_amls(move_line)
|
|
|
|
def _js_action_select_reconcile_model(self, reco_model_id):
|
|
"""Apply a reconciliation model by its ID."""
|
|
self.ensure_one()
|
|
reco_model = self.env['account.reconcile.model'].browse(reco_model_id)
|
|
self._action_select_reconcile_model(reco_model)
|
|
|
|
def _js_action_mount_line_in_edit(self, line_index):
|
|
"""Select a widget entry for editing in the manual operations form."""
|
|
self.ensure_one()
|
|
self.form_index = line_index
|
|
|
|
def _js_action_line_changed(self, form_index, field_name):
|
|
"""Handle a field value change on a widget entry from the JS frontend.
|
|
|
|
Invalidates the field cache to trigger recomputation of dependent
|
|
fields, then dispatches to the appropriate _line_value_changed_* handler.
|
|
"""
|
|
self.ensure_one()
|
|
target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)
|
|
|
|
# Force recomputation by invalidating and re-setting the value
|
|
current_value = target_entry[field_name]
|
|
target_entry.invalidate_recordset(fnames=[field_name], flush=False)
|
|
target_entry[field_name] = current_value
|
|
|
|
handler = getattr(self, f'_line_value_changed_{field_name}')
|
|
handler(target_entry)
|
|
|
|
def _js_action_line_set_partner_receivable_account(self, form_index):
|
|
"""Switch the entry's account to the partner's receivable account."""
|
|
self.ensure_one()
|
|
target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)
|
|
target_entry.account_id = target_entry.partner_receivable_account_id
|
|
self._line_value_changed_account_id(target_entry)
|
|
|
|
def _js_action_line_set_partner_payable_account(self, form_index):
|
|
"""Switch the entry's account to the partner's payable account."""
|
|
self.ensure_one()
|
|
target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)
|
|
target_entry.account_id = target_entry.partner_payable_account_id
|
|
self._line_value_changed_account_id(target_entry)
|
|
|
|
def _js_action_redirect_to_move(self, form_index):
|
|
"""Open the source document (invoice, payment, or journal entry) in a new form."""
|
|
self.ensure_one()
|
|
target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)
|
|
source_move = target_entry.source_aml_move_id
|
|
|
|
redirect_action = {
|
|
'type': 'ir.actions.act_window',
|
|
'context': {'create': False},
|
|
'view_mode': 'form',
|
|
}
|
|
|
|
if source_move.origin_payment_id:
|
|
redirect_action['res_model'] = 'account.payment'
|
|
redirect_action['res_id'] = source_move.origin_payment_id.id
|
|
else:
|
|
redirect_action['res_model'] = 'account.move'
|
|
redirect_action['res_id'] = source_move.id
|
|
|
|
self.return_todo_command = clean_action(redirect_action, self.env)
|
|
|
|
def _js_action_apply_line_suggestion(self, form_index):
|
|
"""Apply the computed suggestion amounts to a matched entry.
|
|
|
|
Reads the suggestion values first to avoid dependency conflicts,
|
|
then applies them and triggers the appropriate change handler.
|
|
"""
|
|
self.ensure_one()
|
|
target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)
|
|
|
|
# Capture suggestion values before modifying fields they depend on
|
|
suggested_foreign = target_entry.suggestion_amount_currency
|
|
suggested_comp = target_entry.suggestion_balance
|
|
|
|
target_entry.amount_currency = suggested_foreign
|
|
target_entry.balance = suggested_comp
|
|
|
|
if target_entry.currency_id == target_entry.company_currency_id:
|
|
self._line_value_changed_balance(target_entry)
|
|
else:
|
|
self._line_value_changed_amount_currency(target_entry)
|
|
|
|
# =========================================================================
|
|
# GLOBAL INFO
|
|
# =========================================================================
|
|
|
|
@api.model
|
|
def collect_global_info_data(self, journal_id):
|
|
"""Retrieve the current statement balance for display in the widget header."""
|
|
journal = self.env['account.journal'].browse(journal_id)
|
|
formatted_balance = ''
|
|
if (
|
|
journal.exists()
|
|
and any(
|
|
company in journal.company_id._accessible_branches()
|
|
for company in self.env.companies
|
|
)
|
|
):
|
|
display_currency = journal.currency_id or journal.company_id.sudo().currency_id
|
|
formatted_balance = formatLang(
|
|
self.env,
|
|
journal.current_statement_balance,
|
|
currency_obj=display_currency,
|
|
)
|
|
return {'balance_amount': formatted_balance}
|