Files
Odoo-Modules/Fusion Accounting/models/bank_rec_widget_line.py
2026-02-22 01:22:18 -05:00

698 lines
28 KiB
Python

# Fusion Accounting - Bank Reconciliation Widget Line
# Original implementation for Fusion Accounting module
import uuid
import markupsafe
from odoo import _, api, fields, models, Command
from odoo.osv import expression
from odoo.tools.misc import formatLang, frozendict
# Flags that derive their values directly from the source journal entry
_SOURCE_LINKED_FLAGS = frozenset({'aml', 'new_aml', 'liquidity', 'exchange_diff'})
# Flags where the statement line date should be used
_STMT_DATE_FLAGS = frozenset({'liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'})
# Flags that derive partner from the source journal entry
_PARTNER_FROM_SOURCE_FLAGS = frozenset({'aml', 'new_aml'})
# Flags that use the widget's partner
_PARTNER_FROM_WIDGET_FLAGS = frozenset({'liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'})
# Flags that derive currency from the transaction
_TRANSACTION_CURRENCY_FLAGS = frozenset({'auto_balance', 'manual', 'early_payment'})
class FusionBankRecLine(models.Model):
"""Represents a single entry within the bank reconciliation widget.
Each entry has a 'flag' indicating its role in the reconciliation process:
- liquidity: The bank/cash journal item from the statement line
- new_aml: A journal item being matched against the statement line
- aml: An already-reconciled journal item (read-only display)
- exchange_diff: Automatically generated foreign exchange adjustment
- tax_line: Tax amount computed from manual entries
- manual: A user-created write-off or adjustment entry
- early_payment: Discount entry for early payment terms
- auto_balance: System-generated balancing entry
This model exists only in memory; no database table is created.
"""
_name = "bank.rec.widget.line"
_inherit = "analytic.mixin"
_description = "Fusion bank reconciliation entry"
_auto = False
_table_query = "0"
# --- Relationship to parent widget ---
wizard_id = fields.Many2one(comodel_name='bank.rec.widget')
index = fields.Char(compute='_compute_index')
flag = fields.Selection(
selection=[
('liquidity', 'liquidity'),
('new_aml', 'new_aml'),
('aml', 'aml'),
('exchange_diff', 'exchange_diff'),
('tax_line', 'tax_line'),
('manual', 'manual'),
('early_payment', 'early_payment'),
('auto_balance', 'auto_balance'),
],
)
# --- Core accounting fields ---
journal_default_account_id = fields.Many2one(
related='wizard_id.st_line_id.journal_id.default_account_id',
depends=['wizard_id'],
)
account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_account_id',
store=True,
readonly=False,
check_company=True,
domain="""[
('id', '!=', journal_default_account_id),
('account_type', 'not in', ('asset_cash', 'off_balance')),
]""",
)
date = fields.Date(
compute='_compute_date',
store=True,
readonly=False,
)
name = fields.Char(
compute='_compute_name',
store=True,
readonly=False,
)
partner_id = fields.Many2one(
comodel_name='res.partner',
compute='_compute_partner_id',
store=True,
readonly=False,
)
currency_id = fields.Many2one(
comodel_name='res.currency',
compute='_compute_currency_id',
store=True,
readonly=False,
)
company_id = fields.Many2one(related='wizard_id.company_id')
country_code = fields.Char(related='company_id.country_id.code', depends=['company_id'])
company_currency_id = fields.Many2one(related='wizard_id.company_currency_id')
amount_currency = fields.Monetary(
currency_field='currency_id',
compute='_compute_amount_currency',
store=True,
readonly=False,
)
balance = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_balance',
store=True,
readonly=False,
)
# --- Transaction currency fields (from statement line) ---
transaction_currency_id = fields.Many2one(
related='wizard_id.st_line_id.foreign_currency_id',
depends=['wizard_id'],
)
amount_transaction_currency = fields.Monetary(
currency_field='transaction_currency_id',
related='wizard_id.st_line_id.amount_currency',
depends=['wizard_id'],
)
# --- Debit/Credit split ---
debit = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_from_balance',
)
credit = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_from_balance',
)
# --- Tax handling ---
force_price_included_taxes = fields.Boolean()
tax_base_amount_currency = fields.Monetary(currency_field='currency_id')
# --- Source journal entry reference ---
source_aml_id = fields.Many2one(comodel_name='account.move.line')
source_aml_move_id = fields.Many2one(
comodel_name='account.move',
compute='_compute_source_aml_fields',
store=True,
readonly=False,
)
source_aml_move_name = fields.Char(
compute='_compute_source_aml_fields',
store=True,
readonly=False,
)
# --- Tax detail fields ---
tax_repartition_line_id = fields.Many2one(
comodel_name='account.tax.repartition.line',
compute='_compute_tax_repartition_line_id',
store=True,
readonly=False,
)
tax_ids = fields.Many2many(
comodel_name='account.tax',
compute='_compute_tax_ids',
store=True,
readonly=False,
check_company=True,
)
tax_tag_ids = fields.Many2many(
comodel_name='account.account.tag',
compute='_compute_tax_tag_ids',
store=True,
readonly=False,
)
group_tax_id = fields.Many2one(
comodel_name='account.tax',
compute='_compute_group_tax_id',
store=True,
readonly=False,
)
# --- Reconcile model tracking ---
reconcile_model_id = fields.Many2one(comodel_name='account.reconcile.model')
# --- Original (pre-partial) amounts for comparison ---
source_amount_currency = fields.Monetary(currency_field='currency_id')
source_balance = fields.Monetary(currency_field='company_currency_id')
source_debit = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_from_source_balance',
)
source_credit = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_from_source_balance',
)
# --- Visual indicators for partial amounts ---
display_stroked_amount_currency = fields.Boolean(compute='_compute_display_stroked_amount_currency')
display_stroked_balance = fields.Boolean(compute='_compute_display_stroked_balance')
# --- Partner account info for UI suggestions ---
partner_currency_id = fields.Many2one(
comodel_name='res.currency',
compute='_compute_partner_info',
)
partner_receivable_account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_partner_info',
)
partner_payable_account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_partner_info',
)
partner_receivable_amount = fields.Monetary(
currency_field='partner_currency_id',
compute='_compute_partner_info',
)
partner_payable_amount = fields.Monetary(
currency_field='partner_currency_id',
compute='_compute_partner_info',
)
# --- Display fields ---
bank_account = fields.Char(compute='_compute_bank_account')
suggestion_html = fields.Html(
compute='_compute_suggestion',
sanitize=False,
)
suggestion_amount_currency = fields.Monetary(
currency_field='currency_id',
compute='_compute_suggestion',
)
suggestion_balance = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_suggestion',
)
ref = fields.Char(
compute='_compute_ref_narration',
store=True,
readonly=False,
)
narration = fields.Html(
compute='_compute_ref_narration',
store=True,
readonly=False,
)
manually_modified = fields.Boolean()
# =========================================================================
# COMPUTE METHODS
# =========================================================================
def _compute_index(self):
"""Assign a unique identifier to each entry for JS-side tracking."""
for entry in self:
entry.index = uuid.uuid4()
@api.depends('source_aml_id')
def _compute_account_id(self):
"""Derive the account from the source journal item for linked entries.
Entries tied to actual journal items (aml, new_aml, liquidity, exchange_diff)
inherit the account directly. Other entry types retain their current account.
"""
for entry in self:
if entry.flag in _SOURCE_LINKED_FLAGS:
entry.account_id = entry.source_aml_id.account_id
else:
entry.account_id = entry.account_id
@api.depends('source_aml_id')
def _compute_date(self):
"""Set the date based on the entry type.
Source-linked entries (aml, new_aml, exchange_diff) use the original journal
item date. Statement-based entries use the statement line date.
"""
for entry in self:
if entry.flag in _STMT_DATE_FLAGS:
entry.date = entry.wizard_id.st_line_id.date
elif entry.flag in ('aml', 'new_aml', 'exchange_diff'):
entry.date = entry.source_aml_id.date
else:
entry.date = entry.date
@api.depends('source_aml_id')
def _compute_name(self):
"""Set the description/label from the source journal item when applicable.
For entries derived from journal items, the label is taken from the
original item. If the source has no name (e.g. credit notes), the
move name is used as fallback.
"""
for entry in self:
if entry.flag in ('aml', 'new_aml', 'liquidity'):
entry.name = entry.source_aml_id.name or entry.source_aml_move_name
else:
entry.name = entry.name
@api.depends('source_aml_id')
def _compute_partner_id(self):
"""Determine the partner for each entry based on its type.
Matched journal items carry their own partner. Statement-derived
entries use the partner set on the reconciliation widget.
"""
for entry in self:
if entry.flag in _PARTNER_FROM_SOURCE_FLAGS:
entry.partner_id = entry.source_aml_id.partner_id
elif entry.flag in _PARTNER_FROM_WIDGET_FLAGS:
entry.partner_id = entry.wizard_id.partner_id
else:
entry.partner_id = entry.partner_id
@api.depends('source_aml_id')
def _compute_currency_id(self):
"""Set the currency based on entry type.
Source-linked entries use the currency from the original journal item.
Transaction-related entries (auto_balance, manual, early_payment) use
the transaction currency from the bank statement.
"""
for entry in self:
if entry.flag in _SOURCE_LINKED_FLAGS:
entry.currency_id = entry.source_aml_id.currency_id
elif entry.flag in _TRANSACTION_CURRENCY_FLAGS:
entry.currency_id = entry.wizard_id.transaction_currency_id
else:
entry.currency_id = entry.currency_id
@api.depends('source_aml_id')
def _compute_balance(self):
"""Set the company-currency balance from the source when applicable.
Only 'aml' and 'liquidity' entries copy the balance directly from the
source journal item. All other types preserve their computed/manual balance.
"""
for entry in self:
if entry.flag in ('aml', 'liquidity'):
entry.balance = entry.source_aml_id.balance
else:
entry.balance = entry.balance
@api.depends('source_aml_id')
def _compute_amount_currency(self):
"""Set the foreign currency amount from the source when applicable.
Only 'aml' and 'liquidity' entries copy directly from the source.
"""
for entry in self:
if entry.flag in ('aml', 'liquidity'):
entry.amount_currency = entry.source_aml_id.amount_currency
else:
entry.amount_currency = entry.amount_currency
@api.depends('balance')
def _compute_from_balance(self):
"""Split the balance into separate debit and credit components."""
for entry in self:
entry.debit = max(entry.balance, 0.0)
entry.credit = max(-entry.balance, 0.0)
@api.depends('source_balance')
def _compute_from_source_balance(self):
"""Split the original source balance into debit and credit."""
for entry in self:
entry.source_debit = max(entry.source_balance, 0.0)
entry.source_credit = max(-entry.source_balance, 0.0)
@api.depends('source_aml_id', 'account_id', 'partner_id')
def _compute_analytic_distribution(self):
"""Compute analytic distribution based on entry type.
Source-linked entries (liquidity, aml) inherit from the source item.
Tax/early-payment entries keep their current distribution. Other entries
look up the default distribution from analytic distribution models.
"""
distribution_cache = {}
for entry in self:
if entry.flag in ('liquidity', 'aml'):
entry.analytic_distribution = entry.source_aml_id.analytic_distribution
elif entry.flag in ('tax_line', 'early_payment'):
entry.analytic_distribution = entry.analytic_distribution
else:
lookup_params = frozendict({
"partner_id": entry.partner_id.id,
"partner_category_id": entry.partner_id.category_id.ids,
"account_prefix": entry.account_id.code,
"company_id": entry.company_id.id,
})
if lookup_params not in distribution_cache:
distribution_cache[lookup_params] = (
self.env['account.analytic.distribution.model']._get_distribution(lookup_params)
)
entry.analytic_distribution = distribution_cache[lookup_params] or entry.analytic_distribution
@api.depends('source_aml_id')
def _compute_tax_repartition_line_id(self):
"""Inherit tax repartition line from the source for 'aml' entries only."""
for entry in self:
if entry.flag == 'aml':
entry.tax_repartition_line_id = entry.source_aml_id.tax_repartition_line_id
else:
entry.tax_repartition_line_id = entry.tax_repartition_line_id
@api.depends('source_aml_id')
def _compute_tax_ids(self):
"""Copy applied tax references from the source for 'aml' entries."""
for entry in self:
if entry.flag == 'aml':
entry.tax_ids = [Command.set(entry.source_aml_id.tax_ids.ids)]
else:
entry.tax_ids = entry.tax_ids
@api.depends('source_aml_id')
def _compute_tax_tag_ids(self):
"""Copy tax tags from the source for 'aml' entries."""
for entry in self:
if entry.flag == 'aml':
entry.tax_tag_ids = [Command.set(entry.source_aml_id.tax_tag_ids.ids)]
else:
entry.tax_tag_ids = entry.tax_tag_ids
@api.depends('source_aml_id')
def _compute_group_tax_id(self):
"""Copy the group tax reference from the source for 'aml' entries."""
for entry in self:
if entry.flag == 'aml':
entry.group_tax_id = entry.source_aml_id.group_tax_id
else:
entry.group_tax_id = entry.group_tax_id
@api.depends('currency_id', 'amount_currency', 'source_amount_currency')
def _compute_display_stroked_amount_currency(self):
"""Determine whether to show a strikethrough on the foreign currency amount.
This visual indicator appears when a 'new_aml' entry has been partially
matched (its current amount differs from the original source amount).
"""
for entry in self:
is_modified = entry.currency_id.compare_amounts(
entry.amount_currency, entry.source_amount_currency
) != 0
entry.display_stroked_amount_currency = entry.flag == 'new_aml' and is_modified
@api.depends('currency_id', 'balance', 'source_balance')
def _compute_display_stroked_balance(self):
"""Determine whether to show a strikethrough on the balance.
Applies to 'new_aml' and 'exchange_diff' entries whose balance
has been adjusted from the original source value.
"""
for entry in self:
balance_changed = entry.currency_id.compare_amounts(
entry.balance, entry.source_balance
) != 0
entry.display_stroked_balance = (
entry.flag in ('new_aml', 'exchange_diff') and balance_changed
)
@api.depends('flag')
def _compute_source_aml_fields(self):
"""Resolve the originating move for display and navigation purposes.
For 'new_aml' and 'liquidity' entries, this is simply the move containing
the source journal item. For 'aml' (already reconciled) entries, we trace
through partial reconciliation records to find the counterpart document.
"""
for entry in self:
entry.source_aml_move_id = None
entry.source_aml_move_name = None
if entry.flag in ('new_aml', 'liquidity'):
originating_move = entry.source_aml_id.move_id
entry.source_aml_move_id = originating_move
entry.source_aml_move_name = originating_move.name
elif entry.flag == 'aml':
# Trace through reconciliation partials to find the counterpart
partial_records = (
entry.source_aml_id.matched_debit_ids
+ entry.source_aml_id.matched_credit_ids
)
linked_items = partial_records.debit_move_id + partial_records.credit_move_id
# Exclude the source itself and any exchange difference entries
fx_move_items = partial_records.exchange_move_id.line_ids
counterpart_items = linked_items - entry.source_aml_id - fx_move_items
if len(counterpart_items) == 1:
entry.source_aml_move_id = counterpart_items.move_id
entry.source_aml_move_name = counterpart_items.move_id.name
@api.depends('wizard_id.form_index', 'partner_id')
def _compute_partner_info(self):
"""Load receivable/payable account info for the selected partner.
This data is used by the UI to offer account switching suggestions
when a partner is set on a manual entry. Only computed for the
entry currently being edited (matching the form_index).
"""
for entry in self:
# Set defaults
entry.partner_receivable_amount = 0.0
entry.partner_payable_amount = 0.0
entry.partner_currency_id = None
entry.partner_receivable_account_id = None
entry.partner_payable_account_id = None
# Only compute for the actively edited entry with a partner
if not entry.partner_id or entry.index != entry.wizard_id.form_index:
continue
entry.partner_currency_id = entry.company_currency_id
scoped_partner = entry.partner_id.with_company(entry.wizard_id.company_id)
posted_filter = [('parent_state', '=', 'posted'), ('partner_id', '=', scoped_partner.id)]
# Receivable info
recv_account = scoped_partner.property_account_receivable_id
entry.partner_receivable_account_id = recv_account
if recv_account:
recv_domain = expression.AND([posted_filter, [('account_id', '=', recv_account.id)]])
recv_data = self.env['account.move.line']._read_group(
domain=recv_domain,
aggregates=['amount_residual:sum'],
)
entry.partner_receivable_amount = recv_data[0][0]
# Payable info
pay_account = scoped_partner.property_account_payable_id
entry.partner_payable_account_id = pay_account
if pay_account:
pay_domain = expression.AND([posted_filter, [('account_id', '=', pay_account.id)]])
pay_data = self.env['account.move.line']._read_group(
domain=pay_domain,
aggregates=['amount_residual:sum'],
)
entry.partner_payable_amount = pay_data[0][0]
@api.depends('flag')
def _compute_bank_account(self):
"""Show the bank account number on the liquidity entry only."""
for entry in self:
if entry.flag == 'liquidity':
stmt_line = entry.wizard_id.st_line_id
displayed_account = stmt_line.partner_bank_id.display_name or stmt_line.account_number
entry.bank_account = displayed_account or None
else:
entry.bank_account = None
@api.depends('wizard_id.form_index', 'amount_currency', 'balance')
def _compute_suggestion(self):
"""Build contextual suggestion text for matched journal items.
When a 'new_aml' entry is being edited, this generates guidance text
explaining the reconciliation impact and offering a quick action button
for full or partial matching.
"""
for entry in self:
entry.suggestion_html = None
entry.suggestion_amount_currency = None
entry.suggestion_balance = None
# Only generate suggestions for actively edited matched entries
if entry.flag != 'new_aml' or entry.index != entry.wizard_id.form_index:
continue
source_item = entry.source_aml_id
parent_widget = entry.wizard_id
original_residual = abs(source_item.amount_residual_currency)
post_match_residual = abs(source_item.amount_residual_currency + entry.amount_currency)
matched_portion = original_residual - post_match_residual
fully_consumed = source_item.currency_id.is_zero(post_match_residual)
belongs_to_invoice = source_item.move_id.is_invoice(include_receipts=True)
# Build the clickable document reference
doc_link_html = markupsafe.Markup(
'<button name="action_redirect_to_move"'
' class="btn btn-link p-0 align-baseline fst-italic">'
'%(doc_name)s</button>'
) % {'doc_name': source_item.move_id.display_name}
# Shared template parameters
tpl_params = {
'amount': formatLang(self.env, matched_portion, currency_obj=source_item.currency_id),
'open_amount': formatLang(self.env, original_residual, currency_obj=source_item.currency_id),
'display_name_html': doc_link_html,
'btn_start': markupsafe.Markup(
'<button name="action_apply_line_suggestion"'
' class="btn btn-link p-0 align-baseline fst-italic">'
),
'btn_end': markupsafe.Markup('</button>'),
}
if fully_consumed:
# Full match scenario
if belongs_to_invoice:
status_msg = _(
"The invoice %(display_name_html)s with an open amount of"
" %(open_amount)s will be entirely paid by the transaction."
)
else:
status_msg = _(
"%(display_name_html)s with an open amount of %(open_amount)s"
" will be fully reconciled by the transaction."
)
suggestion_lines = [status_msg]
# Check if a partial would be more appropriate
partial_data = parent_widget._lines_check_partial_amount(entry)
if partial_data:
if belongs_to_invoice:
partial_msg = _(
"You might want to record a"
" %(btn_start)spartial payment%(btn_end)s."
)
else:
partial_msg = _(
"You might want to make a"
" %(btn_start)spartial reconciliation%(btn_end)s instead."
)
suggestion_lines.append(partial_msg)
entry.suggestion_amount_currency = partial_data['amount_currency']
entry.suggestion_balance = partial_data['balance']
else:
# Partial match scenario - suggest full reconciliation
if belongs_to_invoice:
suggestion_lines = [
_(
"The invoice %(display_name_html)s with an open amount of"
" %(open_amount)s will be reduced by %(amount)s."
),
_(
"You might want to set the invoice as"
" %(btn_start)sfully paid%(btn_end)s."
),
]
else:
suggestion_lines = [
_(
"%(display_name_html)s with an open amount of"
" %(open_amount)s will be reduced by %(amount)s."
),
_(
"You might want to %(btn_start)sfully reconcile%(btn_end)s"
" the document."
),
]
entry.suggestion_amount_currency = entry.source_amount_currency
entry.suggestion_balance = entry.source_balance
rendered_lines = markupsafe.Markup('<br/>').join(
msg % tpl_params for msg in suggestion_lines
)
entry.suggestion_html = (
markupsafe.Markup('<div class="text-muted">%s</div>') % rendered_lines
)
@api.depends('flag')
def _compute_ref_narration(self):
"""Populate ref and narration from the statement line for liquidity entries."""
for entry in self:
if entry.flag == 'liquidity':
entry.ref = entry.wizard_id.st_line_id.ref
entry.narration = entry.wizard_id.st_line_id.narration
else:
entry.ref = None
entry.narration = None
# =========================================================================
# HELPERS
# =========================================================================
def _get_aml_values(self, **kwargs):
"""Convert this widget entry into values suitable for creating journal items.
Returns a dictionary of field values that can be passed to
Command.create() for account.move.line records during validation.
"""
self.ensure_one()
vals = {
'name': self.name,
'account_id': self.account_id.id,
'currency_id': self.currency_id.id,
'amount_currency': self.amount_currency,
'balance': self.debit - self.credit,
'reconcile_model_id': self.reconcile_model_id.id,
'analytic_distribution': self.analytic_distribution,
'tax_repartition_line_id': self.tax_repartition_line_id.id,
'tax_ids': [Command.set(self.tax_ids.ids)],
'tax_tag_ids': [Command.set(self.tax_tag_ids.ids)],
'group_tax_id': self.group_tax_id.id,
}
vals.update(kwargs)
if self.flag == 'early_payment':
vals['display_type'] = 'epd'
return vals