851 lines
33 KiB
Python
851 lines
33 KiB
Python
# 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()
|