# Fusion Accounting - Asset Modification Wizard # Enables users to modify, dispose, sell, pause, or resume # fixed assets with full depreciation board recalculation. from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _, Command from odoo.exceptions import UserError from odoo.tools import float_is_zero from odoo.tools.misc import format_date class AssetModify(models.TransientModel): """Wizard for modifying running assets, including value changes, depreciation parameter adjustments, disposal, and sales.""" _name = 'asset.modify' _description = 'Modify Asset' # --- Core Fields --- name = fields.Text(string='Note') asset_id = fields.Many2one( string="Asset", comodel_name='account.asset', required=True, help="The target asset to modify.", ondelete="cascade", ) currency_id = fields.Many2one( related='asset_id.currency_id', ) company_id = fields.Many2one( comodel_name='res.company', related='asset_id.company_id', ) date = fields.Date( default=lambda self: fields.Date.today(), string='Date', ) # --- Depreciation Parameters --- method_number = fields.Integer( string='Duration', required=True, ) method_period = fields.Selection( selection=[('1', 'Months'), ('12', 'Years')], string='Number of Months in a Period', help="Interval between successive depreciation entries.", ) value_residual = fields.Monetary( string="Depreciable Amount", help="Updated depreciable amount for the asset.", compute="_compute_value_residual", store=True, readonly=False, ) salvage_value = fields.Monetary( string="Not Depreciable Amount", help="Updated non-depreciable (salvage) amount.", ) # --- Action Selection --- modify_action = fields.Selection( selection="_get_selection_modify_options", string="Action", ) # --- Gross Increase Accounts --- account_asset_id = fields.Many2one( comodel_name='account.account', string="Gross Increase Account", check_company=True, domain="[]", ) account_asset_counterpart_id = fields.Many2one( comodel_name='account.account', check_company=True, domain="[]", string="Asset Counterpart Account", ) account_depreciation_id = fields.Many2one( comodel_name='account.account', check_company=True, domain="[]", string="Depreciation Account", ) account_depreciation_expense_id = fields.Many2one( comodel_name='account.account', check_company=True, domain="[]", string="Expense Account", ) # --- Sale / Disposal Fields --- invoice_ids = fields.Many2many( comodel_name='account.move', string="Customer Invoice", check_company=True, domain="[('move_type', '=', 'out_invoice'), ('state', '=', 'posted')]", help="Invoice(s) linked to this disposal or sale.", ) invoice_line_ids = fields.Many2many( comodel_name='account.move.line', check_company=True, domain="[('move_id', '=', invoice_id), ('display_type', '=', 'product')]", help="Specific invoice line(s) related to this asset.", ) select_invoice_line_id = fields.Boolean( compute="_compute_select_invoice_line_id", ) gain_value = fields.Boolean( compute="_compute_gain_value", ) # --- Gain/Loss Accounts --- gain_account_id = fields.Many2one( comodel_name='account.account', check_company=True, domain="[]", compute="_compute_accounts", inverse="_inverse_gain_account", readonly=False, compute_sudo=True, help="Account for recording gains on asset disposal.", ) loss_account_id = fields.Many2one( comodel_name='account.account', check_company=True, domain="[]", compute="_compute_accounts", inverse="_inverse_loss_account", readonly=False, compute_sudo=True, help="Account for recording losses on asset disposal.", ) # --- Informational --- informational_text = fields.Html( compute='_compute_informational_text', ) gain_or_loss = fields.Selection( selection=[('gain', 'Gain'), ('loss', 'Loss'), ('no', 'No')], compute='_compute_gain_or_loss', ) # ------------------------------------------------------------------------- # Selection Helpers # ------------------------------------------------------------------------- def _compute_modify_action(self): """Determine the default action based on context.""" if self.env.context.get('resume_after_pause'): return 'resume' return 'dispose' @api.depends('asset_id') def _get_selection_modify_options(self): """Return available modification actions.""" if self.env.context.get('resume_after_pause'): return [('resume', _('Resume'))] return [ ('dispose', _("Dispose")), ('sell', _("Sell")), ('modify', _("Re-evaluate")), ('pause', _("Pause")), ] # ------------------------------------------------------------------------- # Compute Methods # ------------------------------------------------------------------------- @api.depends('company_id') def _compute_accounts(self): for rec in self: rec.gain_account_id = rec.company_id.gain_account_id rec.loss_account_id = rec.company_id.loss_account_id @api.depends('date') def _compute_value_residual(self): for rec in self: rec.value_residual = rec.asset_id._get_residual_value_at_date(rec.date) def _inverse_gain_account(self): for rec in self: rec.company_id.sudo().gain_account_id = rec.gain_account_id def _inverse_loss_account(self): for rec in self: rec.company_id.sudo().loss_account_id = rec.loss_account_id @api.depends('asset_id', 'invoice_ids', 'invoice_line_ids', 'modify_action', 'date') def _compute_gain_or_loss(self): """Determine whether disposing/selling results in a gain or loss.""" for rec in self: invoice_total = abs(sum( ln.balance for ln in rec.invoice_line_ids )) book_val = rec.asset_id._get_own_book_value(rec.date) cmp_result = rec.company_id.currency_id.compare_amounts( book_val, invoice_total, ) if rec.modify_action in ('sell', 'dispose') and cmp_result < 0: rec.gain_or_loss = 'gain' elif rec.modify_action in ('sell', 'dispose') and cmp_result > 0: rec.gain_or_loss = 'loss' else: rec.gain_or_loss = 'no' @api.depends('asset_id', 'value_residual', 'salvage_value') def _compute_gain_value(self): """Check whether the modification increases the asset's book value.""" for rec in self: rec.gain_value = rec.currency_id.compare_amounts( rec._get_own_book_value(), rec.asset_id._get_own_book_value(rec.date), ) > 0 @api.depends('loss_account_id', 'gain_account_id', 'gain_or_loss', 'modify_action', 'date', 'value_residual', 'salvage_value') def _compute_informational_text(self): """Generate user-facing description of what will happen.""" for wiz in self: formatted_date = format_date(self.env, wiz.date) if wiz.modify_action == 'dispose': acct_name, result_label = '', _('gain/loss') if wiz.gain_or_loss == 'gain': acct_name = wiz.gain_account_id.display_name or '' result_label = _('gain') elif wiz.gain_or_loss == 'loss': acct_name = wiz.loss_account_id.display_name or '' result_label = _('loss') wiz.informational_text = _( "Depreciation will be posted through %(date)s." "
A disposal entry will go to the %(account_type)s " "account %(account)s.", date=formatted_date, account_type=result_label, account=acct_name, ) elif wiz.modify_action == 'sell': acct_name = '' if wiz.gain_or_loss == 'gain': acct_name = wiz.gain_account_id.display_name or '' elif wiz.gain_or_loss == 'loss': acct_name = wiz.loss_account_id.display_name or '' wiz.informational_text = _( "Depreciation will be posted through %(date)s." "
A secondary entry will neutralize the original " "revenue and post the sale outcome to account " "%(account)s.", date=formatted_date, account=acct_name, ) elif wiz.modify_action == 'pause': wiz.informational_text = _( "Depreciation will be posted through %s.", formatted_date, ) elif wiz.modify_action == 'modify': increase_note = ( _("A child asset will be created for the value increase.
") if wiz.gain_value else "" ) wiz.informational_text = _( "Depreciation will be posted through %(date)s.
" "%(extra_text)s Future entries will be recalculated to " "reflect the updated parameters.", date=formatted_date, extra_text=increase_note, ) else: # Resume or other increase_note = ( _("A child asset will be created for the value increase.
") if wiz.gain_value else "" ) wiz.informational_text = _( "%s Future entries will be recalculated to reflect " "the updated parameters.", increase_note, ) @api.depends('invoice_ids', 'modify_action') def _compute_select_invoice_line_id(self): for rec in self: rec.select_invoice_line_id = ( rec.modify_action == 'sell' and len(rec.invoice_ids.invoice_line_ids) > 1 ) # ------------------------------------------------------------------------- # Onchange # ------------------------------------------------------------------------- @api.onchange('modify_action') def _onchange_action(self): if ( self.modify_action == 'sell' and self.asset_id.children_ids.filtered( lambda child: child.state in ('draft', 'open') or child.value_residual > 0 ) ): raise UserError(_( "Cannot automate the sale journal entry for an asset " "with active gross increases. Please dispose of the " "increase(s) first." )) if self.modify_action not in ('modify', 'resume'): self.write({ 'value_residual': self.asset_id._get_residual_value_at_date(self.date), 'salvage_value': self.asset_id.salvage_value, }) @api.onchange('invoice_ids') def _onchange_invoice_ids(self): """Keep invoice_line_ids in sync when invoices change.""" valid_line_ids = self.invoice_ids.invoice_line_ids.filtered( lambda ln: ln._origin.id in self.invoice_line_ids.ids, ) self.invoice_line_ids = valid_line_ids # Auto-select lines for single-line invoices for inv in self.invoice_ids.filtered(lambda i: len(i.invoice_line_ids) == 1): self.invoice_line_ids += inv.invoice_line_ids # ------------------------------------------------------------------------- # CRUD Override # ------------------------------------------------------------------------- @api.model_create_multi def create(self, vals_list): """Populate defaults from the linked asset when not provided.""" AssetModel = self.env['account.asset'] for vals in vals_list: if 'asset_id' not in vals: continue asset = AssetModel.browse(vals['asset_id']) # Block if future posted depreciation exists future_posted = asset.depreciation_move_ids.filtered( lambda mv: ( mv.state == 'posted' and not mv.reversal_move_ids and mv.date > fields.Date.today() ) ) if future_posted: raise UserError(_( 'Please reverse any future-dated depreciation entries ' 'before modifying this asset.' )) # Fill in missing defaults from the asset field_defaults = { 'method_number': asset.method_number, 'method_period': asset.method_period, 'salvage_value': asset.salvage_value, 'account_asset_id': asset.account_asset_id.id, 'account_depreciation_id': asset.account_depreciation_id.id, 'account_depreciation_expense_id': asset.account_depreciation_expense_id.id, } for fld, default_val in field_defaults.items(): if fld not in vals: vals[fld] = default_val return super().create(vals_list) # ------------------------------------------------------------------------- # Main Business Actions # ------------------------------------------------------------------------- def modify(self): """Re-evaluate the asset: update depreciation parameters, handle value increases/decreases, and recompute the schedule.""" lock_threshold = self.asset_id.company_id._get_user_fiscal_lock_date( self.asset_id.journal_id, ) if self.date <= lock_threshold: raise UserError(_( "The selected date is on or before the fiscal lock date. " "Re-evaluation is not permitted." )) # Snapshot current values for change tracking prior_vals = { 'method_number': self.asset_id.method_number, 'method_period': self.asset_id.method_period, 'value_residual': self.asset_id.value_residual, 'salvage_value': self.asset_id.salvage_value, } updated_vals = { 'method_number': self.method_number, 'method_period': self.method_period, 'salvage_value': self.salvage_value, 'account_asset_id': self.account_asset_id, 'account_depreciation_id': self.account_depreciation_id, 'account_depreciation_expense_id': self.account_depreciation_expense_id, } # Handle resume-after-pause scenario is_resuming = self.env.context.get('resume_after_pause') if is_resuming: latest_depr = self.asset_id.depreciation_move_ids last_depr_date = ( max(latest_depr, key=lambda m: m.date).date if latest_depr else self.asset_id.acquisition_date ) gap_days = self.asset_id._get_delta_days(last_depr_date, self.date) - 1 if self.currency_id.compare_amounts(gap_days, 0) < 0: raise UserError(_( "Resume date must be after the pause date." )) updated_vals['asset_paused_days'] = ( self.asset_id.asset_paused_days + gap_days ) updated_vals['state'] = 'open' self.asset_id.message_post( body=_("Asset resumed. %s", self.name), ) # Compute value changes current_book = self.asset_id._get_own_book_value(self.date) target_book = self._get_own_book_value() book_increase = target_book - current_book new_residual, new_salvage = self._get_new_asset_values(current_book) residual_diff = max(0, self.value_residual - new_residual) salvage_diff = max(0, self.salvage_value - new_salvage) # Create depreciation up to modification date (unless resuming) if not is_resuming: draft_before = self.env['account.move'].search_count([ ('asset_id', '=', self.asset_id.id), ('state', '=', 'draft'), ('date', '<=', self.date), ], limit=1) if draft_before: raise UserError(_( 'Unposted depreciation entries exist before the ' 'selected date. Please process them first.' )) self.asset_id._create_move_before_date(self.date) updated_vals['salvage_value'] = new_salvage # Detect if child assets need recomputation schedule_changed = ( updated_vals['method_number'] != self.asset_id.method_number or updated_vals['method_period'] != self.asset_id.method_period or ( updated_vals.get('asset_paused_days') and not float_is_zero( updated_vals['asset_paused_days'] - self.asset_id.asset_paused_days, 8, ) ) ) self.asset_id.write(updated_vals) # Create gross increase asset if value went up total_increase = residual_diff + salvage_diff if self.currency_id.compare_amounts(total_increase, 0) > 0: increase_date = self.date + relativedelta(days=1) increase_entry = self.env['account.move'].create({ 'journal_id': self.asset_id.journal_id.id, 'date': increase_date, 'move_type': 'entry', 'asset_move_type': 'positive_revaluation', 'line_ids': [ Command.create({ 'account_id': self.account_asset_id.id, 'debit': total_increase, 'credit': 0, 'name': _( 'Value increase for: %(asset)s', asset=self.asset_id.name, ), }), Command.create({ 'account_id': self.account_asset_counterpart_id.id, 'debit': 0, 'credit': total_increase, 'name': _( 'Value increase for: %(asset)s', asset=self.asset_id.name, ), }), ], }) increase_entry._post() child_asset = self.env['account.asset'].create({ 'name': ( self.asset_id.name + ': ' + self.name if self.name else "" ), 'currency_id': self.asset_id.currency_id.id, 'company_id': self.asset_id.company_id.id, 'method': self.asset_id.method, 'method_number': self.method_number, 'method_period': self.method_period, 'method_progress_factor': self.asset_id.method_progress_factor, 'acquisition_date': increase_date, 'value_residual': residual_diff, 'salvage_value': salvage_diff, 'prorata_date': increase_date, 'prorata_computation_type': ( 'daily_computation' if self.asset_id.prorata_computation_type == 'daily_computation' else 'constant_periods' ), 'original_value': self._get_increase_original_value( residual_diff, salvage_diff, ), 'account_asset_id': self.account_asset_id.id, 'account_depreciation_id': self.account_depreciation_id.id, 'account_depreciation_expense_id': self.account_depreciation_expense_id.id, 'journal_id': self.asset_id.journal_id.id, 'parent_id': self.asset_id.id, 'original_move_line_ids': [ (6, 0, increase_entry.line_ids.filtered( lambda ln: ln.account_id == self.account_asset_id, ).ids), ], }) child_asset.validate() link_html = child_asset._get_html_link() self.asset_id.message_post( body=_('Gross increase created: %(link)s', link=link_html), ) # Create negative revaluation entry if value went down if self.currency_id.compare_amounts(book_increase, 0) < 0: depr_vals = self.env['account.move']._prepare_move_for_asset_depreciation({ 'amount': -book_increase, 'asset_id': self.asset_id, 'move_ref': _( 'Value decrease for: %(asset)s', asset=self.asset_id.name, ), 'depreciation_beginning_date': self.date, 'depreciation_end_date': self.date, 'date': self.date, 'asset_number_days': 0, 'asset_value_change': True, 'asset_move_type': 'negative_revaluation', }) self.env['account.move'].create(depr_vals)._post() # Recompute depreciation schedule board_start = self.date if is_resuming else self.date + relativedelta(days=1) if self.asset_id.depreciation_move_ids: self.asset_id.compute_depreciation_board(board_start) else: self.asset_id.compute_depreciation_board() # Propagate changes to child assets if schedule params changed if schedule_changed: child_assets = self.asset_id.children_ids child_assets.write({ 'method_number': updated_vals['method_number'], 'method_period': updated_vals['method_period'], 'asset_paused_days': self.asset_id.asset_paused_days, }) for child in child_assets: if not is_resuming: child._create_move_before_date(self.date) if child.depreciation_move_ids: child.compute_depreciation_board(board_start) else: child.compute_depreciation_board() child._check_depreciations() child.depreciation_move_ids.filtered( lambda mv: mv.state != 'posted', )._post() # Log tracked changes in the chatter tracked_fields = self.env['account.asset'].fields_get(prior_vals.keys()) changes, tracking_vals = self.asset_id._mail_track( tracked_fields, prior_vals, ) if changes: self.asset_id.message_post( body=_('Depreciation board modified %s', self.name), tracking_value_ids=tracking_vals, ) self.asset_id._check_depreciations() self.asset_id.depreciation_move_ids.filtered( lambda mv: mv.state != 'posted', )._post() return {'type': 'ir.actions.act_window_close'} def pause(self): """Pause depreciation for the asset.""" for rec in self: rec.asset_id.pause(pause_date=rec.date, message=self.name) def sell_dispose(self): """Dispose of or sell the asset, generating closing entries.""" self.ensure_one() if ( self.gain_account_id == self.asset_id.account_depreciation_id or self.loss_account_id == self.asset_id.account_depreciation_id ): raise UserError(_( "The gain/loss account cannot be the same as the " "Depreciation Account." )) disposal_lines = ( self.env['account.move.line'] if self.modify_action == 'dispose' else self.invoice_line_ids ) return self.asset_id.set_to_close( invoice_line_ids=disposal_lines, date=self.date, message=self.name, ) # ------------------------------------------------------------------------- # Utility Methods # ------------------------------------------------------------------------- def _get_own_book_value(self): """Return the wizard's configured book value (residual + salvage).""" return self.value_residual + self.salvage_value def _get_increase_original_value(self, residual_increase, salvage_increase): """Compute the original value for a gross increase child asset.""" return residual_increase + salvage_increase def _get_new_asset_values(self, current_asset_book): """Calculate capped residual and salvage values to ensure they don't exceed the current book value.""" self.ensure_one() capped_residual = min( current_asset_book - min(self.salvage_value, self.asset_id.salvage_value), self.value_residual, ) capped_salvage = min( current_asset_book - capped_residual, self.salvage_value, ) return capped_residual, capped_salvage