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

317 lines
12 KiB
Python

# Fusion Accounting - Multicurrency Revaluation Wizard
# Creates journal entries to adjust for exchange rate differences
# on foreign-currency denominated balances, with automatic reversal.
import json
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError
from odoo.tools import format_date
class MulticurrencyRevaluationWizard(models.TransientModel):
"""Generates revaluation journal entries that capture unrealized
gains or losses from exchange rate fluctuations, then creates
an auto-reversal entry for the following period."""
_name = 'account.multicurrency.revaluation.wizard'
_description = 'Multicurrency Revaluation Wizard'
# --- Organization ---
company_id = fields.Many2one(
comodel_name='res.company',
default=lambda self: self.env.company,
)
# --- Accounting Configuration ---
journal_id = fields.Many2one(
comodel_name='account.journal',
compute='_compute_accounting_values',
inverse='_inverse_revaluation_journal',
compute_sudo=True,
domain=[('type', '=', 'general')],
required=True,
readonly=False,
)
date = fields.Date(
default=lambda self: self.env.context[
'multicurrency_revaluation_report_options'
]['date']['date_to'],
required=True,
)
reversal_date = fields.Date(required=True)
expense_provision_account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_accounting_values',
inverse='_inverse_expense_provision_account',
compute_sudo=True,
string="Expense Account",
required=True,
readonly=False,
)
income_provision_account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_accounting_values',
inverse='_inverse_income_provision_account',
compute_sudo=True,
string="Income Account",
required=True,
readonly=False,
)
# --- Preview & Warnings ---
preview_data = fields.Text(
compute='_compute_preview_data',
)
show_warning_move_id = fields.Many2one(
comodel_name='account.move',
compute='_compute_show_warning',
)
# -------------------------------------------------------------------------
# Defaults
# -------------------------------------------------------------------------
@api.model
def default_get(self, default_fields):
"""Initialize reversal date and validate that adjustments are needed."""
result = super().default_get(default_fields)
if 'reversal_date' in default_fields:
report_opts = self.env.context['multicurrency_revaluation_report_options']
period_end = fields.Date.to_date(report_opts['date']['date_to'])
result['reversal_date'] = period_end + relativedelta(days=1)
# Verify there is actually something to adjust
if (
not self.env.context.get('revaluation_no_loop')
and not self.with_context(
revaluation_no_loop=True,
)._get_move_vals()['line_ids']
):
raise UserError(_("No currency adjustment is needed."))
return result
# -------------------------------------------------------------------------
# Compute Methods
# -------------------------------------------------------------------------
@api.depends('expense_provision_account_id', 'income_provision_account_id',
'reversal_date')
def _compute_show_warning(self):
"""Warn if there's an unreversed entry on the provision accounts."""
AML = self.env['account.move.line']
for wiz in self:
provision_accts = (
wiz.expense_provision_account_id
+ wiz.income_provision_account_id
)
recent_entry = AML.search([
('account_id', 'in', provision_accts.ids),
('date', '<', wiz.reversal_date),
], order='date desc', limit=1).move_id
wiz.show_warning_move_id = (
False if recent_entry.reversed_entry_id else recent_entry
)
@api.depends('expense_provision_account_id', 'income_provision_account_id',
'date', 'journal_id')
def _compute_preview_data(self):
"""Build JSON data for the move preview widget."""
col_definitions = [
{'field': 'account_id', 'label': _("Account")},
{'field': 'name', 'label': _("Label")},
{'field': 'debit', 'label': _("Debit"), 'class': 'text-end text-nowrap'},
{'field': 'credit', 'label': _("Credit"), 'class': 'text-end text-nowrap'},
]
for wiz in self:
move_vals = self._get_move_vals()
preview_groups = [
self.env['account.move']._move_dict_to_preview_vals(
move_vals, wiz.company_id.currency_id,
),
]
wiz.preview_data = json.dumps({
'groups_vals': preview_groups,
'options': {'columns': col_definitions},
})
@api.depends('company_id')
def _compute_accounting_values(self):
"""Load revaluation settings from the company."""
for wiz in self:
wiz.journal_id = wiz.company_id.account_revaluation_journal_id
wiz.expense_provision_account_id = (
wiz.company_id.account_revaluation_expense_provision_account_id
)
wiz.income_provision_account_id = (
wiz.company_id.account_revaluation_income_provision_account_id
)
# -------------------------------------------------------------------------
# Inverse Methods (persist settings back to company)
# -------------------------------------------------------------------------
def _inverse_revaluation_journal(self):
for wiz in self:
wiz.company_id.sudo().account_revaluation_journal_id = wiz.journal_id
def _inverse_expense_provision_account(self):
for wiz in self:
wiz.company_id.sudo().account_revaluation_expense_provision_account_id = (
wiz.expense_provision_account_id
)
def _inverse_income_provision_account(self):
for wiz in self:
wiz.company_id.sudo().account_revaluation_income_provision_account_id = (
wiz.income_provision_account_id
)
# -------------------------------------------------------------------------
# Move Value Construction
# -------------------------------------------------------------------------
@api.model
def _get_move_vals(self):
"""Build the journal entry values from the multicurrency
revaluation report data, creating a pair of lines per
account/currency that needs adjustment."""
def _extract_model_id(parsed_segments, target_model):
"""Extract the record ID for a given model from parsed line segments."""
for _dummy, seg_model, seg_id in parsed_segments:
if seg_model == target_model:
return seg_id
return None
def _extract_adjustment(report_line):
"""Pull the adjustment amount from the report line columns."""
for col in report_line.get('columns', []):
if col.get('expression_label') == 'adjustment':
return col.get('no_format')
return None
report = self.env.ref('fusion_accounting.multicurrency_revaluation_report')
included_section = report.line_ids.filtered(
lambda ln: ln.code == 'multicurrency_included',
)
included_line_id = report._get_generic_line_id(
'account.report.line', included_section.id,
)
report_options = {
**self.env.context['multicurrency_revaluation_report_options'],
'unfold_all': False,
}
all_report_lines = report._get_lines(report_options)
entry_lines = []
for rpt_line in report._get_unfolded_lines(
all_report_lines, included_line_id,
):
parsed = report._parse_line_id(rpt_line.get('id'))
adj_balance = _extract_adjustment(rpt_line)
# Only process account-level lines with non-zero adjustments
if parsed[-1][-2] != 'account.account':
continue
if self.env.company.currency_id.is_zero(adj_balance):
continue
target_account = _extract_model_id(parsed, 'account.account')
target_currency = _extract_model_id(parsed, 'res.currency')
currency_name = self.env['res.currency'].browse(
target_currency,
).display_name
company_cur_name = self.env.company.currency_id.display_name
current_rate = report_options['currency_rates'][
str(target_currency)
]['rate']
# Account adjustment line
entry_lines.append(Command.create({
'name': _(
"Provision for %(for_cur)s "
"(1 %(comp_cur)s = %(rate)s %(for_cur)s)",
for_cur=currency_name,
comp_cur=company_cur_name,
rate=current_rate,
),
'debit': adj_balance if adj_balance > 0 else 0,
'credit': -adj_balance if adj_balance < 0 else 0,
'amount_currency': 0,
'currency_id': target_currency,
'account_id': target_account,
}))
# Provision counterpart line
if adj_balance < 0:
provision_label = _(
"Expense Provision for %s", currency_name,
)
provision_account = self.expense_provision_account_id.id
else:
provision_label = _(
"Income Provision for %s", currency_name,
)
provision_account = self.income_provision_account_id.id
entry_lines.append(Command.create({
'name': provision_label,
'debit': -adj_balance if adj_balance < 0 else 0,
'credit': adj_balance if adj_balance > 0 else 0,
'amount_currency': 0,
'currency_id': target_currency,
'account_id': provision_account,
}))
return {
'ref': _(
"Foreign currency adjustment as of %s",
format_date(self.env, self.date),
),
'journal_id': self.journal_id.id,
'date': self.date,
'line_ids': entry_lines,
}
# -------------------------------------------------------------------------
# Main Action
# -------------------------------------------------------------------------
def create_entries(self):
"""Create the revaluation entry and its automatic reversal."""
self.ensure_one()
move_data = self._get_move_vals()
if not move_data['line_ids']:
raise UserError(_("No provision adjustment is required."))
# Create and post the revaluation entry
reval_move = self.env['account.move'].create(move_data)
reval_move.action_post()
# Create and post the reversal
reversal = reval_move._reverse_moves(default_values_list=[{
'ref': _("Reversal of: %s", reval_move.ref),
}])
reversal.date = self.reversal_date
reversal.action_post()
# Open the revaluation entry in form view
form_view = self.env.ref('account.view_move_form', False)
clean_ctx = {
k: v for k, v in self.env.context.items()
if k != 'id'
}
return {
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'res_id': reval_move.id,
'view_mode': 'form',
'view_id': form_view.id,
'views': [(form_view.id, 'form')],
'context': clean_ctx,
}