Initial commit
This commit is contained in:
850
Fusion Accounting/wizard/account_reconcile_wizard.py
Normal file
850
Fusion Accounting/wizard/account_reconcile_wizard.py
Normal file
@@ -0,0 +1,850 @@
|
||||
# Fusion Accounting - Manual Reconciliation Wizard
|
||||
# Handles partial/full reconciliation of selected journal items with
|
||||
# optional write-off, account transfers, and tax support.
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, Command, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import groupby, SQL
|
||||
from odoo.tools.misc import formatLang
|
||||
|
||||
|
||||
class AccountReconcileWizard(models.TransientModel):
|
||||
"""Interactive wizard for reconciling selected journal items,
|
||||
optionally generating write-off or account transfer entries."""
|
||||
|
||||
_name = 'account.reconcile.wizard'
|
||||
_description = 'Account reconciliation wizard'
|
||||
_check_company_auto = True
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Default Values
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def default_get(self, field_names):
|
||||
"""Validate and load the selected journal items from the context."""
|
||||
result = super().default_get(field_names)
|
||||
if 'move_line_ids' not in field_names:
|
||||
return result
|
||||
|
||||
active_model = self.env.context.get('active_model')
|
||||
active_ids = self.env.context.get('active_ids')
|
||||
if active_model != 'account.move.line' or not active_ids:
|
||||
raise UserError(_('This wizard can only be used on journal items.'))
|
||||
|
||||
selected_lines = self.env['account.move.line'].browse(active_ids)
|
||||
involved_accounts = selected_lines.account_id
|
||||
|
||||
if len(involved_accounts) > 2:
|
||||
raise UserError(_(
|
||||
'Reconciliation is limited to at most two accounts: %s',
|
||||
', '.join(involved_accounts.mapped('display_name')),
|
||||
))
|
||||
|
||||
# When two accounts are involved, shadow the secondary account
|
||||
override_map = None
|
||||
if len(involved_accounts) == 2:
|
||||
primary_account = selected_lines[0].account_id
|
||||
override_map = {
|
||||
line: {'account_id': primary_account}
|
||||
for line in selected_lines
|
||||
if line.account_id != primary_account
|
||||
}
|
||||
|
||||
selected_lines._check_amls_exigibility_for_reconciliation(
|
||||
shadowed_aml_values=override_map,
|
||||
)
|
||||
result['move_line_ids'] = [Command.set(selected_lines.ids)]
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Field Declarations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
required=True,
|
||||
readonly=True,
|
||||
compute='_compute_company_id',
|
||||
)
|
||||
move_line_ids = fields.Many2many(
|
||||
comodel_name='account.move.line',
|
||||
string='Move lines to reconcile',
|
||||
required=True,
|
||||
)
|
||||
reco_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string='Reconcile Account',
|
||||
compute='_compute_reco_wizard_data',
|
||||
)
|
||||
amount = fields.Monetary(
|
||||
string='Amount in company currency',
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_reco_wizard_data',
|
||||
)
|
||||
company_currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
string='Company currency',
|
||||
related='company_id.currency_id',
|
||||
)
|
||||
amount_currency = fields.Monetary(
|
||||
string='Amount',
|
||||
currency_field='reco_currency_id',
|
||||
compute='_compute_reco_wizard_data',
|
||||
)
|
||||
reco_currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
string='Currency to use for reconciliation',
|
||||
compute='_compute_reco_wizard_data',
|
||||
)
|
||||
edit_mode_amount = fields.Monetary(
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_edit_mode_amount',
|
||||
)
|
||||
edit_mode_amount_currency = fields.Monetary(
|
||||
string='Edit mode amount',
|
||||
currency_field='edit_mode_reco_currency_id',
|
||||
compute='_compute_edit_mode_amount_currency',
|
||||
store=True,
|
||||
readonly=False,
|
||||
)
|
||||
edit_mode_reco_currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
compute='_compute_edit_mode_reco_currency',
|
||||
)
|
||||
edit_mode = fields.Boolean(
|
||||
compute='_compute_edit_mode',
|
||||
)
|
||||
single_currency_mode = fields.Boolean(
|
||||
compute='_compute_single_currency_mode',
|
||||
)
|
||||
allow_partials = fields.Boolean(
|
||||
string="Allow partials",
|
||||
compute='_compute_allow_partials',
|
||||
store=True,
|
||||
readonly=False,
|
||||
)
|
||||
force_partials = fields.Boolean(
|
||||
compute='_compute_reco_wizard_data',
|
||||
)
|
||||
display_allow_partials = fields.Boolean(
|
||||
compute='_compute_display_allow_partials',
|
||||
)
|
||||
date = fields.Date(
|
||||
string='Date',
|
||||
compute='_compute_date',
|
||||
store=True,
|
||||
readonly=False,
|
||||
)
|
||||
journal_id = fields.Many2one(
|
||||
comodel_name='account.journal',
|
||||
string='Journal',
|
||||
check_company=True,
|
||||
domain="[('type', '=', 'general')]",
|
||||
compute='_compute_journal_id',
|
||||
store=True,
|
||||
readonly=False,
|
||||
required=True,
|
||||
precompute=True,
|
||||
)
|
||||
account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string='Account',
|
||||
check_company=True,
|
||||
domain="[('account_type', '!=', 'off_balance')]",
|
||||
)
|
||||
is_rec_pay_account = fields.Boolean(
|
||||
compute='_compute_is_rec_pay_account',
|
||||
)
|
||||
to_partner_id = fields.Many2one(
|
||||
comodel_name='res.partner',
|
||||
string='Partner',
|
||||
check_company=True,
|
||||
compute='_compute_to_partner_id',
|
||||
store=True,
|
||||
readonly=False,
|
||||
)
|
||||
label = fields.Char(string='Label', default='Write-Off')
|
||||
tax_id = fields.Many2one(
|
||||
comodel_name='account.tax',
|
||||
string='Tax',
|
||||
default=False,
|
||||
check_company=True,
|
||||
)
|
||||
to_check = fields.Boolean(
|
||||
string='To Check',
|
||||
default=False,
|
||||
help='Flag this entry for review if information is uncertain.',
|
||||
)
|
||||
is_write_off_required = fields.Boolean(
|
||||
string='Is a write-off move required to reconcile',
|
||||
compute='_compute_is_write_off_required',
|
||||
)
|
||||
is_transfer_required = fields.Boolean(
|
||||
string='Is an account transfer required',
|
||||
compute='_compute_reco_wizard_data',
|
||||
)
|
||||
transfer_warning_message = fields.Char(
|
||||
string='Transfer warning message',
|
||||
compute='_compute_reco_wizard_data',
|
||||
)
|
||||
transfer_from_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string='Account Transfer From',
|
||||
compute='_compute_reco_wizard_data',
|
||||
)
|
||||
lock_date_violated_warning_message = fields.Char(
|
||||
string='Lock date violation warning',
|
||||
compute='_compute_lock_date_violated_warning_message',
|
||||
)
|
||||
reco_model_id = fields.Many2one(
|
||||
comodel_name='account.reconcile.model',
|
||||
string='Reconciliation model',
|
||||
store=False,
|
||||
check_company=True,
|
||||
)
|
||||
reco_model_autocomplete_ids = fields.Many2many(
|
||||
comodel_name='account.reconcile.model',
|
||||
string='All reconciliation models',
|
||||
compute='_compute_reco_model_autocomplete_ids',
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Compute Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.depends('move_line_ids.company_id')
|
||||
def _compute_company_id(self):
|
||||
for wiz in self:
|
||||
wiz.company_id = wiz.move_line_ids[0].company_id
|
||||
|
||||
@api.depends('move_line_ids')
|
||||
def _compute_single_currency_mode(self):
|
||||
for wiz in self:
|
||||
foreign_currencies = wiz.move_line_ids.currency_id - wiz.company_currency_id
|
||||
wiz.single_currency_mode = len(foreign_currencies) <= 1
|
||||
|
||||
@api.depends('force_partials')
|
||||
def _compute_allow_partials(self):
|
||||
for wiz in self:
|
||||
wiz.allow_partials = wiz.display_allow_partials and wiz.force_partials
|
||||
|
||||
@api.depends('move_line_ids')
|
||||
def _compute_display_allow_partials(self):
|
||||
"""Show the partial reconciliation checkbox only when both
|
||||
debit and credit items are selected."""
|
||||
for wiz in self:
|
||||
found_debit = found_credit = False
|
||||
for line in wiz.move_line_ids:
|
||||
if line.balance > 0.0 or line.amount_currency > 0.0:
|
||||
found_debit = True
|
||||
elif line.balance < 0.0 or line.amount_currency < 0.0:
|
||||
found_credit = True
|
||||
if found_debit and found_credit:
|
||||
break
|
||||
wiz.display_allow_partials = found_debit and found_credit
|
||||
|
||||
@api.depends('move_line_ids', 'journal_id', 'tax_id')
|
||||
def _compute_date(self):
|
||||
for wiz in self:
|
||||
latest_date = max(ln.date for ln in wiz.move_line_ids)
|
||||
temp_entry = self.env['account.move'].new({
|
||||
'journal_id': wiz.journal_id.id,
|
||||
})
|
||||
wiz.date = temp_entry._get_accounting_date(
|
||||
latest_date, bool(wiz.tax_id),
|
||||
)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_journal_id(self):
|
||||
for wiz in self:
|
||||
wiz.journal_id = self.env['account.journal'].search([
|
||||
*self.env['account.journal']._check_company_domain(wiz.company_id),
|
||||
('type', '=', 'general'),
|
||||
], limit=1)
|
||||
|
||||
@api.depends('account_id')
|
||||
def _compute_is_rec_pay_account(self):
|
||||
for wiz in self:
|
||||
wiz.is_rec_pay_account = (
|
||||
wiz.account_id.account_type
|
||||
in ('asset_receivable', 'liability_payable')
|
||||
)
|
||||
|
||||
@api.depends('is_rec_pay_account')
|
||||
def _compute_to_partner_id(self):
|
||||
for wiz in self:
|
||||
if wiz.is_rec_pay_account:
|
||||
linked_partners = wiz.move_line_ids.partner_id
|
||||
wiz.to_partner_id = linked_partners if len(linked_partners) == 1 else None
|
||||
else:
|
||||
wiz.to_partner_id = None
|
||||
|
||||
@api.depends('amount', 'amount_currency')
|
||||
def _compute_is_write_off_required(self):
|
||||
"""A write-off is needed when the balance does not reach zero."""
|
||||
for wiz in self:
|
||||
company_zero = wiz.company_currency_id.is_zero(wiz.amount)
|
||||
reco_zero = (
|
||||
wiz.reco_currency_id.is_zero(wiz.amount_currency)
|
||||
if wiz.reco_currency_id else True
|
||||
)
|
||||
wiz.is_write_off_required = not company_zero or not reco_zero
|
||||
|
||||
@api.depends('move_line_ids')
|
||||
def _compute_reco_wizard_data(self):
|
||||
"""Compute reconciliation currency, transfer requirements,
|
||||
and write-off amounts from the selected journal items."""
|
||||
|
||||
def _determine_transfer_info(lines, accts):
|
||||
"""When two accounts are involved, decide which one to transfer from."""
|
||||
balance_per_acct = defaultdict(float)
|
||||
for ln in lines:
|
||||
balance_per_acct[ln.account_id] += ln.amount_residual
|
||||
# Transfer from the account with the smaller absolute balance
|
||||
if abs(balance_per_acct[accts[0]]) < abs(balance_per_acct[accts[1]]):
|
||||
src_acct, dst_acct = accts[0], accts[1]
|
||||
else:
|
||||
src_acct, dst_acct = accts[1], accts[0]
|
||||
|
||||
transfer_lines = lines.filtered(lambda ln: ln.account_id == src_acct)
|
||||
foreign_curs = lines.currency_id - lines.company_currency_id
|
||||
if len(foreign_curs) == 1:
|
||||
xfer_currency = foreign_curs
|
||||
xfer_amount = sum(ln.amount_currency for ln in transfer_lines)
|
||||
else:
|
||||
xfer_currency = lines.company_currency_id
|
||||
xfer_amount = sum(ln.balance for ln in transfer_lines)
|
||||
|
||||
if xfer_amount == 0.0 and xfer_currency != lines.company_currency_id:
|
||||
xfer_currency = lines.company_currency_id
|
||||
xfer_amount = sum(ln.balance for ln in transfer_lines)
|
||||
|
||||
formatted_amt = formatLang(
|
||||
self.env, abs(xfer_amount), currency_obj=xfer_currency,
|
||||
)
|
||||
warning_msg = _(
|
||||
'An entry will transfer %(amount)s from %(from_account)s to %(to_account)s.',
|
||||
amount=formatted_amt,
|
||||
from_account=(src_acct.display_name if xfer_amount < 0
|
||||
else dst_acct.display_name),
|
||||
to_account=(dst_acct.display_name if xfer_amount < 0
|
||||
else src_acct.display_name),
|
||||
)
|
||||
return {
|
||||
'transfer_from_account_id': src_acct,
|
||||
'reco_account_id': dst_acct,
|
||||
'transfer_warning_message': warning_msg,
|
||||
}
|
||||
|
||||
def _resolve_reco_currency(lines, residual_map):
|
||||
"""Determine the best currency for reconciliation."""
|
||||
base_cur = lines.company_currency_id
|
||||
foreign_curs = lines.currency_id - base_cur
|
||||
if not foreign_curs:
|
||||
return base_cur
|
||||
if len(foreign_curs) == 1:
|
||||
return foreign_curs
|
||||
# Multiple foreign currencies - check which have residuals
|
||||
lines_with_balance = self.env['account.move.line']
|
||||
for ln, vals in residual_map.items():
|
||||
if vals['amount_residual'] or vals['amount_residual_currency']:
|
||||
lines_with_balance += ln
|
||||
if lines_with_balance and len(lines_with_balance.currency_id - base_cur) > 1:
|
||||
return False
|
||||
return (lines_with_balance.currency_id - base_cur) or base_cur
|
||||
|
||||
for wiz in self:
|
||||
amls = wiz.move_line_ids._origin
|
||||
accts = amls.account_id
|
||||
|
||||
# Reset defaults
|
||||
wiz.reco_currency_id = False
|
||||
wiz.amount_currency = wiz.amount = 0.0
|
||||
wiz.force_partials = True
|
||||
wiz.transfer_from_account_id = wiz.transfer_warning_message = False
|
||||
wiz.is_transfer_required = len(accts) == 2
|
||||
|
||||
if wiz.is_transfer_required:
|
||||
wiz.update(_determine_transfer_info(amls, accts))
|
||||
else:
|
||||
wiz.reco_account_id = accts
|
||||
|
||||
# Shadow all items to the reconciliation account
|
||||
shadow_vals = {
|
||||
ln: {'account_id': wiz.reco_account_id}
|
||||
for ln in amls
|
||||
}
|
||||
|
||||
# Build reconciliation plan
|
||||
plan_list, all_lines = amls._optimize_reconciliation_plan(
|
||||
[amls], shadowed_aml_values=shadow_vals,
|
||||
)
|
||||
|
||||
# Prefetch for performance
|
||||
all_lines.move_id
|
||||
all_lines.matched_debit_ids
|
||||
all_lines.matched_credit_ids
|
||||
|
||||
residual_map = {
|
||||
ln: {
|
||||
'aml': ln,
|
||||
'amount_residual': ln.amount_residual,
|
||||
'amount_residual_currency': ln.amount_residual_currency,
|
||||
}
|
||||
for ln in all_lines
|
||||
}
|
||||
|
||||
skip_exchange_diff = bool(
|
||||
self.env['ir.config_parameter'].sudo().get_param(
|
||||
'account.disable_partial_exchange_diff',
|
||||
)
|
||||
)
|
||||
plan = plan_list[0]
|
||||
amls.with_context(
|
||||
no_exchange_difference=(
|
||||
self.env.context.get('no_exchange_difference')
|
||||
or skip_exchange_diff
|
||||
),
|
||||
)._prepare_reconciliation_plan(
|
||||
plan, residual_map, shadowed_aml_values=shadow_vals,
|
||||
)
|
||||
|
||||
resolved_cur = _resolve_reco_currency(amls, residual_map)
|
||||
if not resolved_cur:
|
||||
continue
|
||||
|
||||
residual_amounts = {
|
||||
ln: ln._prepare_move_line_residual_amounts(
|
||||
vals, resolved_cur, shadowed_aml_values=shadow_vals,
|
||||
)
|
||||
for ln, vals in residual_map.items()
|
||||
}
|
||||
|
||||
# Verify all residuals are expressed in the resolved currency
|
||||
if all(
|
||||
resolved_cur in rv
|
||||
for rv in residual_amounts.values() if rv
|
||||
):
|
||||
wiz.reco_currency_id = resolved_cur
|
||||
elif all(
|
||||
amls.company_currency_id in rv
|
||||
for rv in residual_amounts.values() if rv
|
||||
):
|
||||
wiz.reco_currency_id = amls.company_currency_id
|
||||
resolved_cur = wiz.reco_currency_id
|
||||
else:
|
||||
continue
|
||||
|
||||
# Compute write-off amounts using the most recent line's rate
|
||||
newest_line = max(amls, key=lambda ln: ln.date)
|
||||
if not newest_line.amount_currency:
|
||||
fx_rate = lower_bound = upper_bound = 0.0
|
||||
elif newest_line.currency_id == resolved_cur:
|
||||
fx_rate = abs(newest_line.balance / newest_line.amount_currency)
|
||||
tolerance = (
|
||||
amls.company_currency_id.rounding / 2
|
||||
/ abs(newest_line.amount_currency)
|
||||
)
|
||||
lower_bound = fx_rate - tolerance
|
||||
upper_bound = fx_rate + tolerance
|
||||
else:
|
||||
fx_rate = self.env['res.currency']._get_conversion_rate(
|
||||
resolved_cur, amls.company_currency_id,
|
||||
amls.company_id, newest_line.date,
|
||||
)
|
||||
lower_bound = upper_bound = fx_rate
|
||||
|
||||
# Identify lines at the correct rate to avoid spurious exchange diffs
|
||||
at_correct_rate = {
|
||||
ln
|
||||
for ln, rv in residual_amounts.items()
|
||||
if (
|
||||
ln.currency_id == resolved_cur
|
||||
and abs(ln.balance) >= ln.company_currency_id.round(
|
||||
abs(ln.amount_currency) * lower_bound
|
||||
)
|
||||
and abs(ln.balance) <= ln.company_currency_id.round(
|
||||
abs(ln.amount_currency) * upper_bound
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
wiz.amount_currency = sum(
|
||||
rv[wiz.reco_currency_id]['residual']
|
||||
for rv in residual_amounts.values() if rv
|
||||
)
|
||||
raw_amount = sum(
|
||||
(
|
||||
rv[amls.company_currency_id]['residual']
|
||||
if ln in at_correct_rate
|
||||
else rv[wiz.reco_currency_id]['residual'] * fx_rate
|
||||
)
|
||||
for ln, rv in residual_amounts.items() if rv
|
||||
)
|
||||
wiz.amount = amls.company_currency_id.round(raw_amount)
|
||||
wiz.force_partials = False
|
||||
|
||||
@api.depends('move_line_ids')
|
||||
def _compute_edit_mode_amount_currency(self):
|
||||
for wiz in self:
|
||||
wiz.edit_mode_amount_currency = (
|
||||
wiz.amount_currency if wiz.edit_mode else 0.0
|
||||
)
|
||||
|
||||
@api.depends('edit_mode_amount_currency')
|
||||
def _compute_edit_mode_amount(self):
|
||||
for wiz in self:
|
||||
if wiz.edit_mode:
|
||||
single_ln = wiz.move_line_ids
|
||||
conversion = (
|
||||
abs(single_ln.amount_currency / single_ln.balance)
|
||||
if single_ln.balance else 0.0
|
||||
)
|
||||
wiz.edit_mode_amount = (
|
||||
single_ln.company_currency_id.round(
|
||||
wiz.edit_mode_amount_currency / conversion
|
||||
) if conversion else 0.0
|
||||
)
|
||||
else:
|
||||
wiz.edit_mode_amount = 0.0
|
||||
|
||||
@api.depends('move_line_ids')
|
||||
def _compute_edit_mode_reco_currency(self):
|
||||
for wiz in self:
|
||||
wiz.edit_mode_reco_currency_id = (
|
||||
wiz.move_line_ids.currency_id if wiz.edit_mode else False
|
||||
)
|
||||
|
||||
@api.depends('move_line_ids')
|
||||
def _compute_edit_mode(self):
|
||||
for wiz in self:
|
||||
wiz.edit_mode = len(wiz.move_line_ids) == 1
|
||||
|
||||
@api.depends('move_line_ids.move_id', 'date')
|
||||
def _compute_lock_date_violated_warning_message(self):
|
||||
for wiz in self:
|
||||
override_date = wiz._get_date_after_lock_date()
|
||||
if override_date:
|
||||
wiz.lock_date_violated_warning_message = _(
|
||||
'The selected date conflicts with a lock date. '
|
||||
'It will be adjusted to: %(replacement_date)s',
|
||||
replacement_date=override_date,
|
||||
)
|
||||
else:
|
||||
wiz.lock_date_violated_warning_message = None
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_reco_model_autocomplete_ids(self):
|
||||
"""Find reconciliation models of type write-off with exactly one line."""
|
||||
for wiz in self:
|
||||
filter_domain = [
|
||||
('rule_type', '=', 'writeoff_button'),
|
||||
('company_id', '=', wiz.company_id.id),
|
||||
('counterpart_type', 'not in', ('sale', 'purchase')),
|
||||
]
|
||||
q = self.env['account.reconcile.model']._where_calc(filter_domain)
|
||||
matching_ids = [
|
||||
row[0] for row in self.env.execute_query(SQL("""
|
||||
SELECT arm.id
|
||||
FROM %s
|
||||
JOIN account_reconcile_model_line arml
|
||||
ON arml.model_id = arm.id
|
||||
WHERE %s
|
||||
GROUP BY arm.id
|
||||
HAVING COUNT(arm.id) = 1
|
||||
""", q.from_clause, q.where_clause or SQL("TRUE")))
|
||||
]
|
||||
wiz.reco_model_autocomplete_ids = (
|
||||
self.env['account.reconcile.model'].browse(matching_ids)
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Onchange
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.onchange('reco_model_id')
|
||||
def _onchange_reco_model_id(self):
|
||||
"""Pre-fill write-off details from the selected reconciliation model."""
|
||||
if self.reco_model_id:
|
||||
model_line = self.reco_model_id.line_ids
|
||||
self.to_check = self.reco_model_id.to_check
|
||||
self.label = model_line.label
|
||||
self.tax_id = model_line.tax_ids[0] if model_line[0].tax_ids else None
|
||||
self.journal_id = model_line.journal_id
|
||||
self.account_id = model_line.account_id
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Constraints
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.constrains('edit_mode_amount_currency')
|
||||
def _check_min_max_edit_mode_amount_currency(self):
|
||||
for wiz in self:
|
||||
if not wiz.edit_mode:
|
||||
continue
|
||||
if wiz.edit_mode_amount_currency == 0.0:
|
||||
raise UserError(_(
|
||||
'The write-off amount for a single line cannot be zero.'
|
||||
))
|
||||
is_debit = (
|
||||
wiz.move_line_ids.balance > 0.0
|
||||
or wiz.move_line_ids.amount_currency > 0.0
|
||||
)
|
||||
if is_debit and wiz.edit_mode_amount_currency < 0.0:
|
||||
raise UserError(_(
|
||||
'The write-off amount for a debit line must be positive.'
|
||||
))
|
||||
if not is_debit and wiz.edit_mode_amount_currency > 0.0:
|
||||
raise UserError(_(
|
||||
'The write-off amount for a credit line must be negative.'
|
||||
))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Actions
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _action_open_wizard(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Write-Off Entry'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_type': 'form',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'account.reconcile.wizard',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Business Logic
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _get_date_after_lock_date(self):
|
||||
"""Return the first valid date if the current date violates a lock."""
|
||||
self.ensure_one()
|
||||
violated = self.company_id._get_violated_lock_dates(
|
||||
self.date, bool(self.tax_id), self.journal_id,
|
||||
)
|
||||
if violated:
|
||||
return violated[-1][0] + timedelta(days=1)
|
||||
|
||||
def _compute_write_off_taxes_data(self, partner):
|
||||
"""Calculate tax breakdown for the write-off entry, including
|
||||
base and per-repartition-line tax amounts."""
|
||||
TaxEngine = self.env['account.tax']
|
||||
wo_amount_currency = self.edit_mode_amount_currency or self.amount_currency
|
||||
wo_amount = self.edit_mode_amount or self.amount
|
||||
conversion_rate = abs(wo_amount_currency / wo_amount)
|
||||
|
||||
tax_usage = self.tax_id.type_tax_use if self.tax_id else None
|
||||
is_refund_entry = (
|
||||
(tax_usage == 'sale' and wo_amount_currency > 0.0)
|
||||
or (tax_usage == 'purchase' and wo_amount_currency < 0.0)
|
||||
)
|
||||
|
||||
base_input = TaxEngine._prepare_base_line_for_taxes_computation(
|
||||
self,
|
||||
partner_id=partner,
|
||||
currency_id=self.reco_currency_id,
|
||||
tax_ids=self.tax_id,
|
||||
price_unit=wo_amount_currency,
|
||||
quantity=1.0,
|
||||
account_id=self.account_id,
|
||||
is_refund=is_refund_entry,
|
||||
rate=conversion_rate,
|
||||
special_mode='total_included',
|
||||
)
|
||||
computation_lines = [base_input]
|
||||
TaxEngine._add_tax_details_in_base_lines(computation_lines, self.company_id)
|
||||
TaxEngine._round_base_lines_tax_details(computation_lines, self.company_id)
|
||||
TaxEngine._add_accounting_data_in_base_lines_tax_details(
|
||||
computation_lines, self.company_id, include_caba_tags=True,
|
||||
)
|
||||
tax_output = TaxEngine._prepare_tax_lines(computation_lines, self.company_id)
|
||||
|
||||
_input_line, base_update = tax_output['base_lines_to_update'][0]
|
||||
tax_detail_list = []
|
||||
for tax_line_vals in tax_output['tax_lines_to_add']:
|
||||
tax_detail_list.append({
|
||||
'tax_amount': tax_line_vals['balance'],
|
||||
'tax_amount_currency': tax_line_vals['amount_currency'],
|
||||
'tax_tag_ids': tax_line_vals['tax_tag_ids'],
|
||||
'tax_account_id': tax_line_vals['account_id'],
|
||||
})
|
||||
|
||||
base_amt_currency = base_update['amount_currency']
|
||||
base_amt = wo_amount - sum(d['tax_amount'] for d in tax_detail_list)
|
||||
|
||||
return {
|
||||
'base_amount': base_amt,
|
||||
'base_amount_currency': base_amt_currency,
|
||||
'base_tax_tag_ids': base_update['tax_tag_ids'],
|
||||
'tax_lines_data': tax_detail_list,
|
||||
}
|
||||
|
||||
def _create_write_off_lines(self, partner=None):
|
||||
"""Build Command.create entries for the write-off journal entry."""
|
||||
if not partner:
|
||||
partner = self.env['res.partner']
|
||||
target_partner = self.to_partner_id if self.is_rec_pay_account else partner
|
||||
tax_info = (
|
||||
self._compute_write_off_taxes_data(target_partner)
|
||||
if self.tax_id else None
|
||||
)
|
||||
wo_amount_currency = self.edit_mode_amount_currency or self.amount_currency
|
||||
wo_amount = self.edit_mode_amount or self.amount
|
||||
|
||||
line_commands = [
|
||||
# Counterpart on reconciliation account
|
||||
Command.create({
|
||||
'name': self.label or _('Write-Off'),
|
||||
'account_id': self.reco_account_id.id,
|
||||
'partner_id': partner.id,
|
||||
'currency_id': self.reco_currency_id.id,
|
||||
'amount_currency': -wo_amount_currency,
|
||||
'balance': -wo_amount,
|
||||
}),
|
||||
# Write-off on target account
|
||||
Command.create({
|
||||
'name': self.label,
|
||||
'account_id': self.account_id.id,
|
||||
'partner_id': target_partner.id,
|
||||
'currency_id': self.reco_currency_id.id,
|
||||
'tax_ids': self.tax_id.ids,
|
||||
'tax_tag_ids': (
|
||||
tax_info['base_tax_tag_ids'] if tax_info else None
|
||||
),
|
||||
'amount_currency': (
|
||||
tax_info['base_amount_currency']
|
||||
if tax_info else wo_amount_currency
|
||||
),
|
||||
'balance': (
|
||||
tax_info['base_amount'] if tax_info else wo_amount
|
||||
),
|
||||
}),
|
||||
]
|
||||
|
||||
# Append one line per tax repartition
|
||||
if tax_info:
|
||||
for detail in tax_info['tax_lines_data']:
|
||||
line_commands.append(Command.create({
|
||||
'name': self.tax_id.name,
|
||||
'account_id': detail['tax_account_id'],
|
||||
'partner_id': target_partner.id,
|
||||
'currency_id': self.reco_currency_id.id,
|
||||
'tax_tag_ids': detail['tax_tag_ids'],
|
||||
'amount_currency': detail['tax_amount_currency'],
|
||||
'balance': detail['tax_amount'],
|
||||
}))
|
||||
|
||||
return line_commands
|
||||
|
||||
def create_write_off(self):
|
||||
"""Create and post a write-off journal entry."""
|
||||
self.ensure_one()
|
||||
linked_partners = self.move_line_ids.partner_id
|
||||
partner = linked_partners if len(linked_partners) == 1 else None
|
||||
|
||||
entry_vals = {
|
||||
'journal_id': self.journal_id.id,
|
||||
'company_id': self.company_id.id,
|
||||
'date': self._get_date_after_lock_date() or self.date,
|
||||
'checked': not self.to_check,
|
||||
'line_ids': self._create_write_off_lines(partner=partner),
|
||||
}
|
||||
wo_move = self.env['account.move'].with_context(
|
||||
skip_invoice_sync=True,
|
||||
skip_invoice_line_sync=True,
|
||||
).create(entry_vals)
|
||||
wo_move.action_post()
|
||||
return wo_move
|
||||
|
||||
def create_transfer(self):
|
||||
"""Create and post an account transfer entry, grouped by partner
|
||||
and currency to maintain correct partner ledger balances."""
|
||||
self.ensure_one()
|
||||
transfer_cmds = []
|
||||
source_lines = self.move_line_ids.filtered(
|
||||
lambda ln: ln.account_id == self.transfer_from_account_id,
|
||||
)
|
||||
|
||||
for (partner, currency), grouped_lines in groupby(
|
||||
source_lines, lambda ln: (ln.partner_id, ln.currency_id),
|
||||
):
|
||||
group_balance = sum(ln.amount_residual for ln in grouped_lines)
|
||||
group_amt_cur = sum(ln.amount_residual_currency for ln in grouped_lines)
|
||||
transfer_cmds.extend([
|
||||
Command.create({
|
||||
'name': _('Transfer from %s', self.transfer_from_account_id.display_name),
|
||||
'account_id': self.reco_account_id.id,
|
||||
'partner_id': partner.id,
|
||||
'currency_id': currency.id,
|
||||
'amount_currency': group_amt_cur,
|
||||
'balance': group_balance,
|
||||
}),
|
||||
Command.create({
|
||||
'name': _('Transfer to %s', self.reco_account_id.display_name),
|
||||
'account_id': self.transfer_from_account_id.id,
|
||||
'partner_id': partner.id,
|
||||
'currency_id': currency.id,
|
||||
'amount_currency': -group_amt_cur,
|
||||
'balance': -group_balance,
|
||||
}),
|
||||
])
|
||||
|
||||
xfer_move = self.env['account.move'].create({
|
||||
'journal_id': self.journal_id.id,
|
||||
'company_id': self.company_id.id,
|
||||
'date': self._get_date_after_lock_date() or self.date,
|
||||
'line_ids': transfer_cmds,
|
||||
})
|
||||
xfer_move.action_post()
|
||||
return xfer_move
|
||||
|
||||
def reconcile(self):
|
||||
"""Execute reconciliation with optional transfer and/or write-off."""
|
||||
self.ensure_one()
|
||||
target_lines = self.move_line_ids._origin
|
||||
needs_transfer = self.is_transfer_required
|
||||
needs_writeoff = (
|
||||
self.edit_mode
|
||||
or (self.is_write_off_required and not self.allow_partials)
|
||||
)
|
||||
|
||||
# Handle account transfer if two accounts are involved
|
||||
if needs_transfer:
|
||||
xfer_move = self.create_transfer()
|
||||
from_lines = target_lines.filtered(
|
||||
lambda ln: ln.account_id == self.transfer_from_account_id,
|
||||
)
|
||||
xfer_from_lines = xfer_move.line_ids.filtered(
|
||||
lambda ln: ln.account_id == self.transfer_from_account_id,
|
||||
)
|
||||
xfer_to_lines = xfer_move.line_ids.filtered(
|
||||
lambda ln: ln.account_id == self.reco_account_id,
|
||||
)
|
||||
(from_lines + xfer_from_lines).reconcile()
|
||||
target_lines = target_lines - from_lines + xfer_to_lines
|
||||
|
||||
# Handle write-off if balance is non-zero
|
||||
if needs_writeoff:
|
||||
wo_move = self.create_write_off()
|
||||
wo_counterpart = wo_move.line_ids[0]
|
||||
target_lines += wo_counterpart
|
||||
reconcile_plan = [[target_lines, wo_counterpart]]
|
||||
else:
|
||||
reconcile_plan = [target_lines]
|
||||
|
||||
self.env['account.move.line']._reconcile_plan(reconcile_plan)
|
||||
|
||||
if needs_transfer:
|
||||
return target_lines + xfer_move.line_ids
|
||||
return target_lines
|
||||
|
||||
def reconcile_open(self):
|
||||
"""Reconcile and open the result in the reconciliation view."""
|
||||
self.ensure_one()
|
||||
return self.reconcile().open_reconcile_view()
|
||||
Reference in New Issue
Block a user