From a401dc3abd83dc08fd46ef08636ea3745fda6202 Mon Sep 17 00:00:00 2001 From: git_admin Date: Tue, 28 Apr 2026 07:35:33 +0000 Subject: [PATCH] Tower: upload at_accounting 18.0.1.7 (via marketplace) --- addons/at_accounting/models/account_asset.py | 1726 ++++++++++++++++++ 1 file changed, 1726 insertions(+) create mode 100644 addons/at_accounting/models/account_asset.py diff --git a/addons/at_accounting/models/account_asset.py b/addons/at_accounting/models/account_asset.py new file mode 100644 index 0000000..753d4e5 --- /dev/null +++ b/addons/at_accounting/models/account_asset.py @@ -0,0 +1,1726 @@ +import psycopg2 +import datetime +from dateutil.relativedelta import relativedelta +from markupsafe import Markup +from math import copysign + +from odoo import api, Command, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare, float_is_zero, formatLang +from odoo.tools.date_utils import end_of + +from odoo.tools import format_date, SQL, Query +from collections import defaultdict + + +DAYS_PER_MONTH = 30 +DAYS_PER_YEAR = DAYS_PER_MONTH * 12 +MAX_NAME_LENGTH = 50 + +class AccountAsset(models.Model): + _name = 'account.asset' + _description = 'Asset/Revenue Recognition' + _inherit = ['mail.thread', 'mail.activity.mixin', 'analytic.mixin'] + + depreciation_entries_count = fields.Integer(compute='_compute_counts', string='# Posted Depreciation Entries') + gross_increase_count = fields.Integer(compute='_compute_counts', string='# Gross Increases', help="Number of assets made to increase the value of the asset") + total_depreciation_entries_count = fields.Integer(compute='_compute_counts', string='# Depreciation Entries', help="Number of depreciation entries (posted or not)") + + name = fields.Char(string='Asset Name', compute='_compute_name', store=True, required=True, readonly=False, tracking=True) + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company) + country_code = fields.Char(related='company_id.account_fiscal_country_id.code') + currency_id = fields.Many2one('res.currency', related='company_id.currency_id', store=True) + state = fields.Selection( + selection=[('model', 'Model'), + ('draft', 'Draft'), + ('open', 'Running'), + ('paused', 'On Hold'), + ('close', 'Closed'), + ('cancelled', 'Cancelled')], + string='Status', + copy=False, + default='draft', + readonly=True, + help="When an asset is created, the status is 'Draft'.\n" + "If the asset is confirmed, the status goes in 'Running' and the depreciation lines can be posted in the accounting.\n" + "The 'On Hold' status can be set manually when you want to pause the depreciation of an asset for some time.\n" + "You can manually close an asset when the depreciation is over.\n" + "By cancelling an asset, all depreciation entries will be reversed") + active = fields.Boolean(default=True) + + # Depreciation params + method = fields.Selection( + selection=[ + ('linear', 'Straight Line'), + ('degressive', 'Declining'), + ('degressive_then_linear', 'Declining then Straight Line') + ], + string='Method', + default='linear', + help="Choose the method to use to compute the amount of depreciation lines.\n" + " * Straight Line: Calculated on basis of: Gross Value / Duration\n" + " * Declining: Calculated on basis of: Residual Value * Declining Factor\n" + " * Declining then Straight Line: Like Declining but with a minimum depreciation value equal to the straight line value." + ) + method_number = fields.Integer(string='Duration', default=5, help="The number of depreciations needed to depreciate your asset") + method_period = fields.Selection([('1', 'Months'), ('12', 'Years')], string='Number of Months in a Period', default='12', + help="The amount of time between two depreciations") + method_progress_factor = fields.Float(string='Declining Factor', default=0.3) + prorata_computation_type = fields.Selection( + selection=[ + ('none', 'No Prorata'), + ('constant_periods', 'Constant Periods'), + ('daily_computation', 'Based on days per period'), + ], + string="Computation", + required=True, default='constant_periods', + ) + prorata_date = fields.Date( + string='Prorata Date', + compute='_compute_prorata_date', store=True, readonly=False, + help='Starting date of the period used in the prorata calculation of the first depreciation', + required=True, precompute=True, + copy=True, + ) + paused_prorata_date = fields.Date(compute='_compute_paused_prorata_date') # number of days to shift the computation of future deprecations + account_asset_id = fields.Many2one( + 'account.account', + string='Fixed Asset Account', + compute='_compute_account_asset_id', + help="Account used to record the purchase of the asset at its original price.", + store=True, readonly=False, + check_company=True, + domain="[('account_type', '!=', 'off_balance')]", + ) + asset_group_id = fields.Many2one('account.asset.group', string='Asset Group', tracking=True, index=True) + account_depreciation_id = fields.Many2one( + comodel_name='account.account', + string='Depreciation Account', + check_company=True, + domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card', 'off_balance')), ('deprecated', '=', False)]", + help="Account used in the depreciation entries, to decrease the asset value." + ) + account_depreciation_expense_id = fields.Many2one( + comodel_name='account.account', + string='Expense Account', + check_company=True, + domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card', 'off_balance')), ('deprecated', '=', False)]", + help="Account used in the periodical entries, to record a part of the asset as expense.", + ) + + journal_id = fields.Many2one( + 'account.journal', + string='Journal', + check_company=True, + domain="[('type', '=', 'general')]", + compute='_compute_journal_id', store=True, readonly=False, + ) + + # Values + original_value = fields.Monetary(string="Original Value", compute='_compute_value', store=True, readonly=False) + book_value = fields.Monetary(string='Book Value', readonly=True, compute='_compute_book_value', recursive=True, store=True, help="Sum of the depreciable value, the salvage value and the book value of all value increase items") + value_residual = fields.Monetary(string='Depreciable Value', compute='_compute_value_residual') + salvage_value = fields.Monetary(string='Not Depreciable Value', + help="It is the amount you plan to have that you cannot depreciate.", + compute="_compute_salvage_value", + store=True, readonly=False) + salvage_value_pct = fields.Float(string='Not Depreciable Value Percent', + help="It is the amount you plan to have that you cannot depreciate.") + total_depreciable_value = fields.Monetary(compute='_compute_total_depreciable_value') + gross_increase_value = fields.Monetary(string="Gross Increase Value", compute="_compute_gross_increase_value", compute_sudo=True) + non_deductible_tax_value = fields.Monetary(string="Non Deductible Tax Value", compute="_compute_non_deductible_tax_value", store=True, readonly=True) + related_purchase_value = fields.Monetary(compute='_compute_related_purchase_value') + + # Links with entries + depreciation_move_ids = fields.One2many('account.move', 'asset_id', string='Depreciation Lines') + original_move_line_ids = fields.Many2many('account.move.line', 'asset_move_line_rel', 'asset_id', 'line_id', string='Journal Items', copy=False) + + asset_properties_definition = fields.PropertiesDefinition('Model Properties') + asset_properties = fields.Properties('Properties', definition='model_id.asset_properties_definition', copy=True) + + # Dates + acquisition_date = fields.Date( + compute='_compute_acquisition_date', store=True, precompute=True, + readonly=False, + copy=True, + ) + disposal_date = fields.Date(readonly=False, compute="_compute_disposal_date", store=True) + + # model-related fields + model_id = fields.Many2one('account.asset', string='Model', change_default=True, domain="[('company_id', '=', company_id)]") + account_type = fields.Selection(string="Type of the account", related='account_asset_id.account_type') + display_account_asset_id = fields.Boolean(compute="_compute_display_account_asset_id") + + # Capital gain + parent_id = fields.Many2one('account.asset', help="An asset has a parent when it is the result of gaining value") + children_ids = fields.One2many('account.asset', 'parent_id', help="The children are the gains in value of this asset") + + # Adapt for import fields + already_depreciated_amount_import = fields.Monetary( + help="In case of an import from another software, you might need to use this field to have the right " + "depreciation table report. This is the value that was already depreciated with entries not computed from this model", + ) + + asset_lifetime_days = fields.Float(compute="_compute_lifetime_days", recursive=True) # total number of days to consider for the computation of an asset depreciation board + asset_paused_days = fields.Float(copy=False) + + net_gain_on_sale = fields.Monetary(string="Net gain on sale", help="Net value of gain or loss on sale of an asset", copy=False) + + linked_assets_ids = fields.One2many( + comodel_name='account.asset', + string="Linked Assets", + compute='_compute_linked_assets', + ) + count_linked_asset = fields.Integer(compute="_compute_linked_assets") + warning_count_assets = fields.Boolean(compute="_compute_linked_assets") + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + @api.depends('company_id') + def _compute_journal_id(self): + for asset in self: + if asset.journal_id and asset.journal_id.company_id == asset.company_id: + asset.journal_id = asset.journal_id + else: + asset.journal_id = self.env['account.journal'].search([ + *self.env['account.journal']._check_company_domain(asset.company_id), + ('type', '=', 'general'), + ], limit=1) + + @api.depends('salvage_value', 'original_value') + def _compute_total_depreciable_value(self): + for asset in self: + asset.total_depreciable_value = asset.original_value - asset.salvage_value + + @api.depends('original_value', 'model_id') + def _compute_salvage_value(self): + for asset in self: + if asset.model_id.salvage_value_pct != 0.0: + asset.salvage_value = asset.original_value * asset.model_id.salvage_value_pct + + @api.depends('depreciation_move_ids.date', 'state') + def _compute_disposal_date(self): + for asset in self: + if asset.state == 'close': + dates = asset.depreciation_move_ids.filtered(lambda m: m.date).mapped('date') + asset.disposal_date = dates and max(dates) + else: + asset.disposal_date = False + + @api.depends('original_move_line_ids', 'original_move_line_ids.account_id', 'non_deductible_tax_value') + def _compute_value(self): + for record in self: + if not record.original_move_line_ids: + record.original_value = record.original_value or False + continue + if any(line.move_id.state == 'draft' for line in record.original_move_line_ids): + raise UserError(_("All the lines should be posted")) + record.original_value = record.related_purchase_value + if record.non_deductible_tax_value: + record.original_value += record.non_deductible_tax_value + + @api.depends('original_move_line_ids') + @api.depends_context('form_view_ref') + def _compute_display_account_asset_id(self): + for record in self: + # Hide the field when creating an asset model from the CoA. (form_view_ref is set from there) + model_from_coa = self.env.context.get('form_view_ref') and record.state == 'model' + record.display_account_asset_id = not record.original_move_line_ids and not model_from_coa + + @api.depends('account_depreciation_id', 'account_depreciation_expense_id', 'original_move_line_ids') + def _compute_account_asset_id(self): + for record in self: + if record.original_move_line_ids: + if len(record.original_move_line_ids.account_id) > 1: + raise UserError(_("All the lines should be from the same account")) + record.account_asset_id = record.original_move_line_ids.account_id + if not record.account_asset_id: + # Only set a default value, do not erase user inputs + record._onchange_account_depreciation_id() + + @api.depends('original_move_line_ids') + def _compute_analytic_distribution(self): + for asset in self: + distribution_asset = {} + amount_total = sum(asset.original_move_line_ids.mapped("balance")) + if not float_is_zero(amount_total, precision_rounding=asset.currency_id.rounding): + for line in asset.original_move_line_ids._origin: + if line.analytic_distribution: + for account, distribution in line.analytic_distribution.items(): + distribution_asset[account] = distribution_asset.get(account, 0) + distribution * line.balance + for account, distribution_amount in distribution_asset.items(): + distribution_asset[account] = distribution_amount / amount_total + asset.analytic_distribution = distribution_asset if distribution_asset else asset.analytic_distribution + + @api.depends('method_number', 'method_period', 'prorata_computation_type') + def _compute_lifetime_days(self): + for asset in self: + if not asset.parent_id: + if asset.prorata_computation_type == 'daily_computation': + asset.asset_lifetime_days = (asset.prorata_date + relativedelta(months=int(asset.method_period) * asset.method_number) - asset.prorata_date).days + else: + asset.asset_lifetime_days = int(asset.method_period) * asset.method_number * DAYS_PER_MONTH + else: + # if it has a parent, we want the asset to only depreciate on the remaining days left of the parent + if asset.prorata_computation_type == 'daily_computation': + parent_end_date = asset.parent_id.paused_prorata_date + relativedelta(days=int(asset.parent_id.asset_lifetime_days - 1)) + else: + parent_end_date = asset.parent_id.paused_prorata_date + relativedelta( + months=int(asset.parent_id.asset_lifetime_days / DAYS_PER_MONTH), + days=int(asset.parent_id.asset_lifetime_days % DAYS_PER_MONTH) - 1 + ) + asset.asset_lifetime_days = asset._get_delta_days(asset.prorata_date, parent_end_date) + + @api.depends('acquisition_date', 'company_id', 'prorata_computation_type') + def _compute_prorata_date(self): + for asset in self: + if asset.prorata_computation_type == 'none' and asset.acquisition_date: + fiscalyear_date = asset.company_id.compute_fiscalyear_dates(asset.acquisition_date).get('date_from') + asset.prorata_date = fiscalyear_date + else: + asset.prorata_date = asset.acquisition_date + + @api.depends('prorata_date', 'prorata_computation_type', 'asset_paused_days') + def _compute_paused_prorata_date(self): + for asset in self: + if asset.prorata_computation_type == 'daily_computation': + asset.paused_prorata_date = asset.prorata_date + relativedelta(days=asset.asset_paused_days) + else: + asset.paused_prorata_date = asset.prorata_date + relativedelta( + months=int(asset.asset_paused_days / DAYS_PER_MONTH), + days=asset.asset_paused_days % DAYS_PER_MONTH + ) + + @api.depends('original_move_line_ids') + def _compute_related_purchase_value(self): + for asset in self: + related_purchase_value = sum(asset.original_move_line_ids.mapped('balance')) + if asset.account_asset_id.multiple_assets_per_line and len(asset.original_move_line_ids) == 1: + related_purchase_value /= max(1, int(asset.original_move_line_ids.quantity)) + asset.related_purchase_value = related_purchase_value + + @api.depends('original_move_line_ids') + def _compute_acquisition_date(self): + for asset in self: + asset.acquisition_date = asset.acquisition_date or min( + [(aml.invoice_date or aml.date) for aml in asset.original_move_line_ids] + [fields.Date.today()] + ) + + @api.depends('original_move_line_ids') + def _compute_name(self): + for record in self: + record.name = record.name or (record.original_move_line_ids and record.original_move_line_ids[0].name or '') + + @api.depends( + 'original_value', 'salvage_value', 'already_depreciated_amount_import', + 'depreciation_move_ids.state', + 'depreciation_move_ids.depreciation_value', + 'depreciation_move_ids.reversal_move_ids' + ) + def _compute_value_residual(self): + for record in self: + posted_depreciation_moves = record.depreciation_move_ids.filtered(lambda mv: mv.state == 'posted') + record.value_residual = ( + record.original_value + - record.salvage_value + - record.already_depreciated_amount_import + - sum(posted_depreciation_moves.mapped('depreciation_value')) + ) + + @api.depends('value_residual', 'salvage_value', 'children_ids.book_value') + def _compute_book_value(self): + for record in self: + record.book_value = record.value_residual + record.salvage_value + sum(record.children_ids.mapped('book_value')) + if record.state == 'close' and all(move.state == 'posted' for move in record.depreciation_move_ids): + record.book_value -= record.salvage_value + + @api.depends('children_ids.original_value') + def _compute_gross_increase_value(self): + for record in self: + record.gross_increase_value = sum(record.children_ids.mapped('original_value')) + + @api.depends('original_move_line_ids') + def _compute_non_deductible_tax_value(self): + for record in self: + record.non_deductible_tax_value = 0.0 + for line in record.original_move_line_ids: + if line.non_deductible_tax_value: + account = line.account_id + auto_create_multi = account.create_asset != 'no' and account.multiple_assets_per_line + quantity = line.quantity if auto_create_multi else 1 + converted_non_deductible_tax_value = line.currency_id._convert(line.non_deductible_tax_value / quantity, record.currency_id, record.company_id, line.date) + record.non_deductible_tax_value += record.currency_id.round(converted_non_deductible_tax_value) + + @api.depends('depreciation_move_ids.state', 'parent_id') + def _compute_counts(self): + depreciation_per_asset = { + group.id: count + for group, count in self.env['account.move']._read_group( + domain=[ + ('asset_id', 'in', self.ids), + ('state', '=', 'posted'), + ], + groupby=['asset_id'], + aggregates=['__count'], + ) + } + for asset in self: + asset.depreciation_entries_count = depreciation_per_asset.get(asset.id, 0) + asset.total_depreciation_entries_count = len(asset.depreciation_move_ids) + asset.gross_increase_count = len(asset.children_ids) + + @api.depends('original_move_line_ids.asset_ids') + def _compute_linked_assets(self): + for asset in self: + asset.linked_assets_ids = asset.original_move_line_ids.asset_ids - self + asset.count_linked_asset = len(asset.linked_assets_ids) + confirmed_assets = asset.linked_assets_ids.filtered(lambda x: x.state == "open") + # The warning_count_assets is useful to put the smart button in red, in case at least one asset has been confirmed + asset.warning_count_assets = len(confirmed_assets) > 0 + + # ------------------------------------------------------------------------- + # ONCHANGE METHODS + # ------------------------------------------------------------------------- + @api.onchange('account_depreciation_id') + def _onchange_account_depreciation_id(self): + if not self.original_move_line_ids: + if not self.account_asset_id and self.state != 'model': + # Only set a default value since it is visible in the form + self.account_asset_id = self.account_depreciation_id + + @api.onchange('original_value', 'original_move_line_ids') + def _display_original_value_warning(self): + if self.original_move_line_ids: + computed_original_value = self.related_purchase_value + self.non_deductible_tax_value + if self.original_value != computed_original_value: + warning = { + 'title': _("Warning for the Original Value of %s", self.name), + 'message': _("The amount you have entered (%(entered_amount)s) does not match the Related Purchase's value (%(purchase_value)s). " + "Please make sure this is what you want.", + entered_amount=formatLang(self.env, self.original_value, currency_obj=self.currency_id), + purchase_value=formatLang(self.env, computed_original_value, currency_obj=self.currency_id)) + } + return {'warning': warning} + + @api.onchange('original_move_line_ids') + def _onchange_original_move_line_ids(self): + # Force the recompute + self.acquisition_date = False + self._compute_acquisition_date() + + @api.onchange('account_asset_id') + def _onchange_account_asset_id(self): + self.account_depreciation_id = self.account_depreciation_id or self.account_asset_id + + @api.onchange('model_id') + def _onchange_model_id(self): + model = self.model_id + if model: + self.method = model.method + self.method_number = model.method_number + self.method_period = model.method_period + self.method_progress_factor = model.method_progress_factor + self.prorata_computation_type = model.prorata_computation_type + self.analytic_distribution = model.analytic_distribution or self.analytic_distribution + self.account_asset_id = model.account_asset_id + self.account_depreciation_id = model.account_depreciation_id + self.account_depreciation_expense_id = model.account_depreciation_expense_id + self.journal_id = model.journal_id + + @api.onchange('original_value', 'salvage_value', 'acquisition_date', 'method', 'method_progress_factor', 'method_period', + 'method_number', 'prorata_computation_type', 'already_depreciated_amount_import', 'prorata_date',) + def onchange_consistent_board(self): + """ When changing the fields that should change the values of the entries, we unlink the entries, so the + depreciation board is not inconsistent with the values of the asset""" + self.write( + {'depreciation_move_ids': [Command.set([])]} + ) + + # ------------------------------------------------------------------------- + # CONSTRAINT METHODS + # ------------------------------------------------------------------------- + @api.constrains('active', 'state') + def _check_active(self): + for record in self: + if not record.active and record.state not in ('close', 'model'): + raise UserError(_('You cannot archive a record that is not closed')) + + @api.constrains('depreciation_move_ids') + def _check_depreciations(self): + for asset in self: + if ( + asset.state == 'open' + and asset.depreciation_move_ids + and not asset.currency_id.is_zero( + asset.depreciation_move_ids.sorted(lambda x: (x.date, x.id))[-1].asset_remaining_value + ) + ): + raise UserError(_("The remaining value on the last depreciation line must be 0")) + + @api.constrains('original_move_line_ids') + def _check_related_purchase(self): + for asset in self: + if asset.original_move_line_ids and asset.related_purchase_value == 0: + raise UserError(_("You cannot create an asset from lines containing credit and debit on the account or with a null amount")) + if asset.state not in ('model', 'draft'): + raise UserError(_("You cannot add or remove bills when the asset is already running or closed.")) + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + @api.ondelete(at_uninstall=True) + def _unlink_if_model_or_draft(self): + for asset in self: + if asset.state in ['open', 'paused', 'close']: + raise UserError(_( + 'You cannot delete a document that is in %s state.', + dict(self._fields['state']._description_selection(self.env)).get(asset.state) + )) + + posted_amount = len(asset.depreciation_move_ids.filtered(lambda x: x.state == 'posted')) + if posted_amount > 0: + raise UserError(_('You cannot delete an asset linked to posted entries.' + '\nYou should either confirm the asset, then, sell or dispose of it,' + ' or cancel the linked journal entries.')) + + def unlink(self): + for asset in self: + for line in asset.original_move_line_ids: + if line.name: + body = _('A document linked to %(move_line_name)s has been deleted: %(link)s', + move_line_name=line.name, + link=asset._get_html_link(), + ) + else: + body = _('A document linked to this move has been deleted: %s', + asset._get_html_link()) + line.move_id.message_post(body=body) + if len(line.move_id.asset_ids) == 1: + line.move_id.asset_move_type = False + return super(AccountAsset, self).unlink() + + def copy_data(self, default=None): + vals_list = super().copy_data(default) + for asset, vals in zip(self, vals_list): + if asset.state == 'model': + vals['state'] = 'model' + vals['name'] = _('%s (copy)', asset.name) + vals['account_asset_id'] = asset.account_asset_id.id + return vals_list + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'state' in vals and vals['state'] != 'draft' and not (set(vals) - set({'account_depreciation_id', 'account_depreciation_expense_id', 'journal_id'})): + raise UserError(_("Some required values are missing")) + if self._context.get('default_state') != 'model' and vals.get('state') != 'model': + vals['state'] = 'draft' + new_recs = super(AccountAsset, self.with_context(mail_create_nolog=True)).create(vals_list) + # if original_value is passed in vals, make sure the right value is set (as a different original_value may have been computed by _compute_value()) + for i, vals in enumerate(vals_list): + if 'original_value' in vals: + new_recs[i].original_value = vals['original_value'] + if self.env.context.get('original_asset'): + # When original_asset is set, only one asset is created since its from the form view + original_asset = self.env['account.asset'].browse(self.env.context.get('original_asset')) + original_asset.model_id = new_recs + return new_recs + + def write(self, vals): + result = super().write(vals) + for asset in self: + for move in asset.depreciation_move_ids: + if move.state == 'draft' and 'analytic_distribution' in vals: + # Only draft entries to avoid recreating all the analytic items + move.line_ids.analytic_distribution = vals['analytic_distribution'] + lock_date = move.company_id._get_user_fiscal_lock_date(asset.journal_id) + if move.date > lock_date: + if 'account_depreciation_id' in vals: + # ::2 (0, 2, 4, ...) because we want all first lines of the depreciation entries, which corresponds to the + # lines with account_depreciation_id as account + move.line_ids[::2].account_id = vals['account_depreciation_id'] + if 'account_depreciation_expense_id' in vals: + # 1::2 (1, 3, 5, ...) because we want all second lines of the depreciation entries, which corresponds to the + # lines with account_depreciation_expense_id as account + move.line_ids[1::2].account_id = vals['account_depreciation_expense_id'] + if 'journal_id' in vals: + move.journal_id = vals['journal_id'] + return result + + # ------------------------------------------------------------------------- + # BOARD COMPUTATION + # ------------------------------------------------------------------------- + def _get_linear_amount(self, days_before_period, days_until_period_end, total_depreciable_value): + + amount_expected_previous_period = total_depreciable_value * days_before_period / self.asset_lifetime_days + amount_after_expected = total_depreciable_value * days_until_period_end / self.asset_lifetime_days + number_days_for_period = days_until_period_end - days_before_period + # In case of a decrease, we need to lower the amount of the depreciation with the amount of the decrease + # spread over the remaining lifetime + amount_of_decrease_spread_over_period = [ + number_days_for_period * mv.depreciation_value / (self.asset_lifetime_days - self._get_delta_days(self.paused_prorata_date, mv.asset_depreciation_beginning_date)) + for mv in self.depreciation_move_ids.filtered(lambda mv: mv.asset_value_change) + ] + computed_linear_amount = self.currency_id.round(amount_after_expected - self.currency_id.round(amount_expected_previous_period) - sum(amount_of_decrease_spread_over_period)) + return computed_linear_amount + + def _compute_board_amount(self, residual_amount, period_start_date, period_end_date, days_already_depreciated, + days_left_to_depreciated, residual_declining, start_yearly_period=None, total_lifetime_left=None, + residual_at_compute=None, start_recompute_date=None): + + def _get_max_between_linear_and_degressive(linear_amount, effective_start_date=start_yearly_period): + """ + Compute the degressive amount that could be depreciated and returns the biggest between it and linear_amount + The degressive amount corresponds to the difference between what should have been depreciated at the end of + the period and the residual_amount (to deal with rounding issues at the end of each month) + """ + fiscalyear_dates = self.company_id.compute_fiscalyear_dates(period_end_date) + days_in_fiscalyear = self._get_delta_days(fiscalyear_dates['date_from'], fiscalyear_dates['date_to']) + + degressive_total_value = residual_declining * (1 - self.method_progress_factor * self._get_delta_days(effective_start_date, period_end_date) / days_in_fiscalyear) + degressive_amount = residual_amount - degressive_total_value + return self._degressive_linear_amount(residual_amount, degressive_amount, linear_amount) + + if float_is_zero(self.asset_lifetime_days, 2) or float_is_zero(residual_amount, 2): + return 0, 0 + + days_until_period_end = self._get_delta_days(self.paused_prorata_date, period_end_date) + days_before_period = self._get_delta_days(self.paused_prorata_date, period_start_date + relativedelta(days=-1)) + days_before_period = max(days_before_period, 0) # if disposed before the beginning of the asset for example + number_days = days_until_period_end - days_before_period + + # The amount to depreciate are computed by computing how much the asset should be depreciated at the end of the + # period minus how much difference it is actually depreciated. It is done that way to avoid having the last move to take + # every single small difference that could appear over the time with the classic computation method. + if self.method == 'linear': + if total_lifetime_left and float_compare(total_lifetime_left, 0, 2) > 0: + computed_linear_amount = residual_amount - residual_at_compute * (1 - self._get_delta_days(start_recompute_date, period_end_date) / total_lifetime_left) + else: + computed_linear_amount = self._get_linear_amount(days_before_period, days_until_period_end, self.total_depreciable_value) + amount = min(computed_linear_amount, residual_amount, key=abs) + elif self.method == 'degressive': + # Linear amount + # We first calculate the total linear amount for the period left from the beginning of the year + # to get the linear amount for the period in order to avoid big delta at the end of the period + effective_start_date = max(start_yearly_period, self.paused_prorata_date) if start_yearly_period else self.paused_prorata_date + days_left_from_beginning_of_year = self._get_delta_days(effective_start_date, period_start_date - relativedelta(days=1)) + days_left_to_depreciated + expected_remaining_value_with_linear = residual_declining - residual_declining * self._get_delta_days(effective_start_date, period_end_date) / days_left_from_beginning_of_year + linear_amount = residual_amount - expected_remaining_value_with_linear + + amount = _get_max_between_linear_and_degressive(linear_amount, effective_start_date) + elif self.method == 'degressive_then_linear': + if not self.parent_id: + linear_amount = self._get_linear_amount(days_before_period, days_until_period_end, self.total_depreciable_value) + else: + # we want to know the amount before the reeval for the parent so the child can follow the same curve, + # so it transitions from degressive to linear at the same moment + parent_moves = self.parent_id.depreciation_move_ids.filtered(lambda mv: mv.date <= self.prorata_date).sorted(key=lambda mv: (mv.date, mv.id)) + parent_cumulative_depreciation = parent_moves[-1].asset_depreciated_value if parent_moves else self.parent_id.already_depreciated_amount_import + parent_depreciable_value = parent_moves[-1].asset_remaining_value if parent_moves else self.parent_id.total_depreciable_value + if self.currency_id.is_zero(parent_depreciable_value): + linear_amount = self._get_linear_amount(days_before_period, days_until_period_end, self.total_depreciable_value) + else: + # To have the same curve as the parent, we need to have the equivalent amount before the reeval. + # The child's depreciable value corresponds to the amount that is left to depreciate for the parent. + # So, we use the proportion between them to compute the equivalent child's total to depreciate. + # We use it then with the duration of the parent to compute the depreciation amount + depreciable_value = self.total_depreciable_value * (1 + parent_cumulative_depreciation/parent_depreciable_value) + linear_amount = self._get_linear_amount(days_before_period, days_until_period_end, depreciable_value) * self.asset_lifetime_days / self.parent_id.asset_lifetime_days + + amount = _get_max_between_linear_and_degressive(linear_amount) + + amount = max(amount, 0) if self.currency_id.compare_amounts(residual_amount, 0) > 0 else min(amount, 0) + amount = self._get_depreciation_amount_end_of_lifetime(residual_amount, amount, days_until_period_end) + + return number_days, self.currency_id.round(amount) + + def compute_depreciation_board(self, date=False): + # Need to unlink draft moves before adding new ones because if we create new moves before, it will cause an error + self.depreciation_move_ids.filtered(lambda mv: mv.state == 'draft' and (mv.date >= date if date else True)).unlink() + + new_depreciation_moves_data = [] + for asset in self: + new_depreciation_moves_data.extend(asset._recompute_board(date)) + + new_depreciation_moves = self.env['account.move'].create(new_depreciation_moves_data) + new_depreciation_moves_to_post = new_depreciation_moves.filtered(lambda move: move.asset_id.state == 'open') + # In case of the asset is in running mode, we post in the past and set to auto post move in the future + new_depreciation_moves_to_post._post() + + def _recompute_board(self, start_depreciation_date=False): + self.ensure_one() + # All depreciation moves that are posted + posted_depreciation_move_ids = self.depreciation_move_ids.filtered( + lambda mv: mv.state == 'posted' and not mv.asset_value_change + ).sorted(key=lambda mv: (mv.date, mv.id)) + + imported_amount = self.already_depreciated_amount_import + residual_amount = self.value_residual - sum(self.depreciation_move_ids.filtered(lambda mv: mv.state == 'draft').mapped('depreciation_value')) + if not posted_depreciation_move_ids: + residual_amount += imported_amount + residual_declining = residual_at_compute = residual_amount + # start_yearly_period is needed in the 'degressive' and 'degressive_then_linear' methods to compute the amount when the period is monthly + start_recompute_date = start_depreciation_date = start_yearly_period = start_depreciation_date or self.paused_prorata_date + + last_day_asset = self._get_last_day_asset() + final_depreciation_date = self._get_end_period_date(last_day_asset) + total_lifetime_left = self._get_delta_days(start_depreciation_date, last_day_asset) + + depreciation_move_values = [] + if not float_is_zero(self.value_residual, precision_rounding=self.currency_id.rounding): + while not self.currency_id.is_zero(residual_amount) and start_depreciation_date < final_depreciation_date: + period_end_depreciation_date = self._get_end_period_date(start_depreciation_date) + period_end_fiscalyear_date = self.company_id.compute_fiscalyear_dates(period_end_depreciation_date).get('date_to') + lifetime_left = self._get_delta_days(start_depreciation_date, last_day_asset) + + days, amount = self._compute_board_amount(residual_amount, start_depreciation_date, period_end_depreciation_date, False, lifetime_left, residual_declining, start_yearly_period, total_lifetime_left, residual_at_compute, start_recompute_date) + residual_amount -= amount + + if not posted_depreciation_move_ids: + # self.already_depreciated_amount_import management. + # Subtracts the imported amount from the first depreciation moves until we reach it + # (might skip several depreciation entries) + if abs(imported_amount) <= abs(amount): + amount -= imported_amount + imported_amount = 0 + else: + imported_amount -= amount + amount = 0 + + if self.method == 'degressive_then_linear' and final_depreciation_date < period_end_depreciation_date: + period_end_depreciation_date = final_depreciation_date + + if not float_is_zero(amount, precision_rounding=self.currency_id.rounding): + # For deferred revenues, we should invert the amounts. + depreciation_move_values.append(self.env['account.move']._prepare_move_for_asset_depreciation({ + 'amount': amount, + 'asset_id': self, + 'depreciation_beginning_date': start_depreciation_date, + 'date': period_end_depreciation_date, + 'asset_number_days': days, + })) + + if period_end_depreciation_date == period_end_fiscalyear_date: + start_yearly_period = self.company_id.compute_fiscalyear_dates(period_end_depreciation_date).get('date_from') + relativedelta(years=1) + residual_declining = residual_amount + + start_depreciation_date = period_end_depreciation_date + relativedelta(days=1) + + return depreciation_move_values + + def _get_end_period_date(self, start_depreciation_date): + """Get the end of the period in which the depreciation is posted. + + Can be the end of the month if the asset is depreciated monthly, or the end of the fiscal year is it is depreciated yearly. + """ + self.ensure_one() + fiscalyear_date = self.company_id.compute_fiscalyear_dates(start_depreciation_date).get('date_to') + period_end_depreciation_date = fiscalyear_date if start_depreciation_date <= fiscalyear_date else fiscalyear_date + relativedelta(years=1) + + if self.method_period == '1': # If method period is set to monthly computation + max_day_in_month = end_of(datetime.date(start_depreciation_date.year, start_depreciation_date.month, 1), 'month').day + period_end_depreciation_date = min(start_depreciation_date.replace(day=max_day_in_month), period_end_depreciation_date) + return period_end_depreciation_date + + def _get_delta_days(self, start_date, end_date): + """Compute how many days there are between 2 dates. + + The computation is different if the asset is in daily_computation or not. + """ + self.ensure_one() + if self.prorata_computation_type == 'daily_computation': + # Compute how many days there are between 2 dates using a daily_computation method + return (end_date - start_date).days + 1 + else: + # Compute how many days there are between 2 dates counting 30 days per month + # Get how many days there are in the start date month + start_date_days_month = end_of(start_date, 'month').day + # Get how many days there are in the start date month (e.g: June 20th: (30 * (30 - 20 + 1)) / 30 = 11) + start_prorata = (start_date_days_month - start_date.day + 1) / start_date_days_month + # Get how many days there are in the end date month (e.g: You're the August 14th: (14 * 30) / 31 = 13.548387096774194) + end_prorata = end_date.day / end_of(end_date, 'month').day + # Compute how many days there are between these 2 dates + # e.g: 13.548387096774194 + 11 + 360 * (2020 - 2020) + 30 * (8 - 6 - 1) = 24.548387096774194 + 360 * 0 + 30 * 1 = 54.548387096774194 day + return sum(( + start_prorata * DAYS_PER_MONTH, + end_prorata * DAYS_PER_MONTH, + (end_date.year - start_date.year) * DAYS_PER_YEAR, + (end_date.month - start_date.month - 1) * DAYS_PER_MONTH + )) + + def _get_last_day_asset(self): + this = self.parent_id if self.parent_id else self + return this.paused_prorata_date + relativedelta(months=int(this.method_period) * this.method_number, days=-1) + + # ------------------------------------------------------------------------- + # PUBLIC ACTIONS + # ------------------------------------------------------------------------- + + def action_open_linked_assets(self): + action = self.linked_assets_ids.open_asset(['list', 'form']) + action.get('context', {}).update({ + 'from_linked_assets': 0, + }) + return action + + def action_asset_modify(self): + """ Returns an action opening the asset modification wizard. + """ + self.ensure_one() + new_wizard = self.env['asset.modify'].create({ + 'asset_id': self.id, + 'modify_action': 'resume' if self.env.context.get('resume_after_pause') else 'dispose', + }) + return { + 'name': _('Modify Asset'), + 'view_mode': 'form', + 'res_model': 'asset.modify', + 'type': 'ir.actions.act_window', + 'target': 'new', + 'res_id': new_wizard.id, + 'context': self.env.context, + } + + def action_save_model(self): + return { + 'name': _('Save model'), + 'views': [[self.env.ref('at_accountingview_account_asset_form').id, "form"]], + 'res_model': 'account.asset', + 'type': 'ir.actions.act_window', + 'context': { + 'default_state': 'model', + 'default_account_asset_id': self.account_asset_id.id, + 'default_account_depreciation_id': self.account_depreciation_id.id, + 'default_account_depreciation_expense_id': self.account_depreciation_expense_id.id, + 'default_journal_id': self.journal_id.id, + 'default_method': self.method, + 'default_method_number': self.method_number, + 'default_method_period': self.method_period, + 'default_method_progress_factor': self.method_progress_factor, + 'default_prorata_date': self.prorata_date, + 'default_prorata_computation_type': self.prorata_computation_type, + 'default_analytic_distribution': self.analytic_distribution, + 'original_asset': self.id, + } + } + + def open_entries(self): + return { + 'name': _('Journal Entries'), + 'view_mode': 'list,form', + 'res_model': 'account.move', + 'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'], + 'views': [(self.env.ref('account.view_move_tree').id, 'list'), (False, 'form')], + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', self.depreciation_move_ids.ids)], + 'context': dict(self._context, create=False), + } + + def open_related_entries(self): + return { + 'name': _('Journal Items'), + 'view_mode': 'list,form', + 'res_model': 'account.move.line', + 'view_id': False, + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', self.original_move_line_ids.ids)], + } + + def open_increase(self): + result = { + 'name': _('Gross Increase'), + 'view_mode': 'list,form', + 'res_model': 'account.asset', + 'context': {**self.env.context, 'create': False}, + 'view_id': False, + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', self.children_ids.ids)], + 'views': [(False, 'list'), (False, 'form')], + } + if len(self.children_ids) == 1: + result['views'] = [(False, 'form')] + result['res_id'] = self.children_ids.id + return result + + def open_parent_id(self): + result = { + 'name': _('Parent Asset'), + 'view_mode': 'form', + 'res_model': 'account.asset', + 'type': 'ir.actions.act_window', + 'res_id': self.parent_id.id, + 'views': [(False, 'form')], + } + return result + + def validate(self): + fields = [ + 'method', + 'method_number', + 'method_period', + 'method_progress_factor', + 'salvage_value', + 'original_move_line_ids', + ] + ref_tracked_fields = self.env['account.asset'].fields_get(fields) + self.write({'state': 'open'}) + for asset in self: + tracked_fields = ref_tracked_fields.copy() + if asset.method == 'linear': + del tracked_fields['method_progress_factor'] + dummy, tracking_value_ids = asset._mail_track(tracked_fields, dict.fromkeys(fields)) + asset_name = (_('Asset created'), _('An asset has been created for this move:')) + msg = asset_name[1] + ' ' + asset._get_html_link() + asset.message_post(body=asset_name[0], tracking_value_ids=tracking_value_ids) + for move_id in asset.original_move_line_ids.mapped('move_id'): + move_id.message_post(body=msg) + try: + if not asset.depreciation_move_ids: + asset.compute_depreciation_board() + asset._check_depreciations() + asset.depreciation_move_ids.filtered(lambda move: move.state != 'posted')._post() + except psycopg2.errors.CheckViolation: + raise ValidationError(_("Atleast one asset (%s) couldn't be set as running because it lacks any required information", asset.name)) + + if asset.account_asset_id.create_asset == 'no': + asset._post_non_deductible_tax_value() + + def set_to_close(self, invoice_line_ids, date=None, message=None): + self.ensure_one() + disposal_date = date or fields.Date.today() + if disposal_date <= self.company_id._get_user_fiscal_lock_date(self.journal_id): + raise UserError(_("You cannot dispose of an asset before the lock date.")) + if invoice_line_ids and self.children_ids.filtered(lambda a: a.state in ('draft', 'open') or a.value_residual > 0): + raise UserError(_("You cannot automate the journal entry for an asset that has a running gross increase. Please use 'Dispose' on the increase(s).")) + full_asset = self + self.children_ids + full_asset.state = 'close' + move_ids = full_asset._get_disposal_moves([invoice_line_ids] * len(full_asset), disposal_date) + for asset in full_asset: + asset.message_post(body= + _('Asset sold. %s', message if message else "") + if invoice_line_ids else + _('Asset disposed. %s', message if message else "") + ) + + selling_price = abs(sum(invoice_line.balance for invoice_line in invoice_line_ids)) + self.net_gain_on_sale = self.currency_id.round(selling_price - self.book_value) + + if move_ids: + name = _('Disposal Move') + view_mode = 'form' + if len(move_ids) > 1: + name = _('Disposal Moves') + view_mode = 'list,form' + return { + 'name': name, + 'view_mode': view_mode, + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'target': 'current', + 'res_id': move_ids[0], + 'domain': [('id', 'in', move_ids)] + } + + def set_to_cancelled(self): + for asset in self: + posted_moves = asset.depreciation_move_ids.filtered(lambda m: ( + not m.reversal_move_ids + and not m.reversed_entry_id + and m.state == 'posted' + )) + if posted_moves: + depreciation_change = sum(posted_moves.line_ids.mapped( + lambda l: l.debit if l.account_id == asset.account_depreciation_expense_id else 0.0 + )) + acc_depreciation_change = sum(posted_moves.line_ids.mapped( + lambda l: l.credit if l.account_id == asset.account_depreciation_id else 0.0 + )) + entries = Markup('
').join(posted_moves.sorted('date').mapped(lambda m: + f'{m.ref} - {m.date} - ' + f'{formatLang(self.env, m.depreciation_value, currency_obj=m.currency_id)} - ' + f'{m.name}' + )) + asset._cancel_future_moves(datetime.date.min) + msg = _('Asset Cancelled') + Markup('
') + \ + _('The account %(exp_acc)s has been credited by %(exp_delta)s, ' + 'while the account %(dep_acc)s has been debited by %(dep_delta)s. ' + 'This corresponds to %(move_count)s cancelled %(word)s:', + exp_acc=asset.account_depreciation_expense_id.display_name, + exp_delta=formatLang(self.env, depreciation_change, currency_obj=asset.currency_id), + dep_acc=asset.account_depreciation_id.display_name, + dep_delta=formatLang(self.env, acc_depreciation_change, currency_obj=asset.currency_id), + move_count=len(posted_moves), + word=_('entries') if len(posted_moves) > 1 else _('entry'), + ) + Markup('
') + entries + asset._message_log(body=msg) + else: + asset._message_log(body=_('Asset Cancelled')) + asset.depreciation_move_ids.filtered(lambda m: m.state == 'draft').with_context(force_delete=True).unlink() + asset.asset_paused_days = 0 + asset.write({'state': 'cancelled'}) + + def set_to_draft(self): + self.write({'state': 'draft'}) + + def set_to_running(self): + if self.depreciation_move_ids and not max(self.depreciation_move_ids, key=lambda m: (m.date, m.id)).asset_remaining_value == 0: + self.env['asset.modify'].create({'asset_id': self.id, 'name': _('Reset to running')}).modify() + self.write({ + 'state': 'open', + 'net_gain_on_sale': 0 + }) + + def resume_after_pause(self): + """ Sets an asset in 'paused' state back to 'open'. + A Depreciation line is created automatically to remove from the + depreciation amount the proportion of time spent + in pause in the current period. + """ + self.ensure_one() + return self.with_context(resume_after_pause=True).action_asset_modify() + + def pause(self, pause_date, message=None): + """ Sets an 'open' asset in 'paused' state, generating first a depreciation + line corresponding to the ratio of time spent within the current depreciation + period before putting the asset in pause. This line and all the previous + unposted ones are then posted. + """ + self.ensure_one() + self._create_move_before_date(pause_date) + self.write({'state': 'paused'}) + self.message_post(body=_("Asset paused. %s", message if message else "")) + + def open_asset(self, view_mode): + if len(self) == 1: + view_mode = ['form'] + views = [v for v in [(False, 'list'), (False, 'form')] if v[1] in view_mode] + ctx = dict(self._context) + ctx.pop('default_move_type', None) + action = { + 'name': _('Asset'), + 'view_mode': ','.join(view_mode), + 'type': 'ir.actions.act_window', + 'res_id': self.id if 'list' not in view_mode else False, + 'res_model': 'account.asset', + 'views': views, + 'domain': [('id', 'in', self.ids)], + 'context': ctx + } + return action + + # ------------------------------------------------------------------------- + # HELPER METHODS + # ------------------------------------------------------------------------- + def _insert_depreciation_line(self, amount, beginning_depreciation_date, depreciation_date, days_depreciated): + """ Inserts a new line in the depreciation board, shifting the sequence of + all the following lines from one unit. + :param amount: The depreciation amount of the new line. + :param label: The name to give to the new line. + :param date: The date to give to the new line. + """ + self.ensure_one() + AccountMove = self.env['account.move'] + + return AccountMove.create(AccountMove._prepare_move_for_asset_depreciation({ + 'amount': amount, + 'asset_id': self, + 'depreciation_beginning_date': beginning_depreciation_date, + 'date': depreciation_date, + 'asset_number_days': days_depreciated, + })) + + def _post_non_deductible_tax_value(self): + # If the asset has a non-deductible tax, the value is posted in the chatter to explain why + # the original value does not match the related purchase(s). + if self.non_deductible_tax_value: + currency = self.env.company.currency_id + msg = _('A non deductible tax value of %(tax_value)s was added to %(name)s\'s initial value of %(purchase_value)s', + tax_value=formatLang(self.env, self.non_deductible_tax_value, currency_obj=currency), + name=self.name, + purchase_value=formatLang(self.env, self.related_purchase_value, currency_obj=currency)) + self.message_post(body=msg) + + def _create_move_before_date(self, date): + """Cancel all the moves after the given date and replace them by a new one. + + The new depreciation/move is depreciating the residual value. + """ + all_move_dates_before_date = (self.depreciation_move_ids.filtered( + lambda x: + x.date <= date + and not x.reversal_move_ids + and not x.reversed_entry_id + and x.state == 'posted' + ).sorted('date')).mapped('date') + + beginning_fiscal_year = self.company_id.compute_fiscalyear_dates(date).get('date_from') if self.method != 'linear' else False + first_fiscalyear_move = self.env['account.move'] + if all_move_dates_before_date: + last_move_date_not_reversed = max(all_move_dates_before_date) + # We don't know when begins the period that the move is supposed to cover + # So, we use the earliest beginning of a move that comes after the last move not cancelled + future_moves_beginning_date = self.depreciation_move_ids.filtered( + lambda m: m.date > last_move_date_not_reversed and ( + not m.reversal_move_ids and not m.reversed_entry_id and m.state == 'posted' + or m.state == 'draft' + ) + ).mapped('asset_depreciation_beginning_date') + beginning_depreciation_date = min(future_moves_beginning_date) if future_moves_beginning_date else self.paused_prorata_date + + if self.method != 'linear': + # In degressive and degressive_then_linear, we need to find the first move of the fiscal year that comes after the last move not cancelled + # in order to correctly compute the moves just before and after the pause date + first_moves = self.depreciation_move_ids.filtered( + lambda m: m.asset_depreciation_beginning_date >= beginning_fiscal_year and ( + not m.reversal_move_ids and not m.reversed_entry_id and m.state == 'posted' + or m.state == 'draft' + ) + ).sorted(lambda m: (m.asset_depreciation_beginning_date, m.id)) + first_fiscalyear_move = next(iter(first_moves), first_fiscalyear_move) + else: + beginning_depreciation_date = self.paused_prorata_date + + residual_declining = first_fiscalyear_move.asset_remaining_value + first_fiscalyear_move.depreciation_value + self._cancel_future_moves(date) + + imported_amount = self.already_depreciated_amount_import if not all_move_dates_before_date else 0 + value_residual = self.value_residual + self.already_depreciated_amount_import if not all_move_dates_before_date else self.value_residual + residual_declining = residual_declining or value_residual + + last_day_asset = self._get_last_day_asset() + lifetime_left = self._get_delta_days(beginning_depreciation_date, last_day_asset) + days_depreciated, amount = self._compute_board_amount(self.value_residual, beginning_depreciation_date, date, False, lifetime_left, residual_declining, beginning_fiscal_year, lifetime_left, value_residual, beginning_depreciation_date) + + if abs(imported_amount) <= abs(amount): + amount -= imported_amount + if not float_is_zero(amount, precision_rounding=self.currency_id.rounding): + new_line = self._insert_depreciation_line(amount, beginning_depreciation_date, date, days_depreciated) + new_line._post() + + def _cancel_future_moves(self, date): + """Cancel all the depreciation entries after the date given as parameter. + + When possible, it will reset those to draft before unlinking them, reverse them otherwise. + + :param date: date after which the moves are deleted/reversed + """ + for asset in self: + obsolete_moves = asset.depreciation_move_ids.filtered(lambda m: m.state == 'draft' or ( + not m.reversal_move_ids + and not m.reversed_entry_id + and m.state == 'posted' + and m.date > date + )) + obsolete_moves._unlink_or_reverse() + + def _get_disposal_moves(self, invoice_lines_list, disposal_date): + """Create the move for the disposal of an asset. + + :param invoice_lines_list: list of recordset of `account.move.line` + Each element of the list corresponds to one record of `self` + These lines are used to generate the disposal move + :param disposal_date: the date of the disposal + """ + def get_line(name, asset, amount, account): + return (0, 0, { + 'name': name, + 'account_id': account.id, + 'balance': -amount, + 'analytic_distribution': analytic_distribution, + 'currency_id': asset.currency_id.id, + 'amount_currency': -asset.company_id.currency_id._convert( + from_amount=amount, + to_currency=asset.currency_id, + company=asset.company_id, + date=disposal_date, + ) + }) + + move_ids = [] + assert len(self) == len(invoice_lines_list) + for asset, invoice_line_ids in zip(self, invoice_lines_list): + asset._create_move_before_date(disposal_date) + + analytic_distribution = asset.analytic_distribution + + dict_invoice = {} + invoice_amount = 0 + + initial_amount = asset.original_value + initial_account = asset.original_move_line_ids.account_id if len(asset.original_move_line_ids.account_id) == 1 else asset.account_asset_id + + all_lines_before_disposal = asset.depreciation_move_ids.filtered(lambda x: x.date <= disposal_date) + depreciated_amount = asset.currency_id.round(copysign( + sum(all_lines_before_disposal.mapped('depreciation_value')) + asset.already_depreciated_amount_import, + -initial_amount, + )) + depreciation_account = asset.account_depreciation_id + for invoice_line in invoice_line_ids: + dict_invoice[invoice_line.account_id] = copysign(invoice_line.balance, -initial_amount) + dict_invoice.get(invoice_line.account_id, 0) + invoice_amount += copysign(invoice_line.balance, -initial_amount) + list_accounts = [(amount, account) for account, amount in dict_invoice.items()] + difference = -initial_amount - depreciated_amount - invoice_amount + difference_account = asset.company_id.gain_account_id if difference > 0 else asset.company_id.loss_account_id + line_datas = [(initial_amount, initial_account), (depreciated_amount, depreciation_account)] + list_accounts + [(difference, difference_account)] + name = _("%(asset)s: Disposal", asset=asset.name) if not invoice_line_ids else _("%(asset)s: Sale", asset=asset.name) + vals = { + 'asset_id': asset.id, + 'ref': name, + 'asset_depreciation_beginning_date': disposal_date, + 'date': disposal_date, + 'journal_id': asset.journal_id.id, + 'move_type': 'entry', + 'asset_move_type': 'disposal' if not invoice_line_ids else 'sale', + 'line_ids': [get_line(name, asset, amount, account) for amount, account in line_datas if account], + } + asset.write({'depreciation_move_ids': [(0, 0, vals)]}) + move_ids += self.env['account.move'].search([('asset_id', '=', asset.id), ('state', '=', 'draft')]).ids + + return move_ids + + def _degressive_linear_amount(self, residual_amount, degressive_amount, linear_amount): + if self.currency_id.compare_amounts(residual_amount, 0) > 0: + return max(degressive_amount, linear_amount) + else: + return min(degressive_amount, linear_amount) + + def _get_depreciation_amount_end_of_lifetime(self, residual_amount, amount, days_until_period_end): + if abs(residual_amount) < abs(amount) or days_until_period_end >= self.asset_lifetime_days: + # If the residual amount is less than the computed amount, we keep the residual amount + # If total_days is greater or equals to asset lifetime days, it should mean that + # the asset will finish in this period and the value for this period is equal to the residual amount. + amount = residual_amount + return amount + + def _get_own_book_value(self, date=None): + self.ensure_one() + return (self._get_residual_value_at_date(date) if date else self.value_residual) + self.salvage_value + + def _get_residual_value_at_date(self, date): + """ Computes the theoretical value of the asset at a specific date. + + :param date: the date at which we want the asset's value + :return: the value at date of the asset without taking reverse entries into account (as it should be in a "normal" flow of the asset) + """ + current_and_previous_depreciation = self.depreciation_move_ids.filtered( + lambda mv: + mv.asset_depreciation_beginning_date < date + and not mv.reversed_entry_id + ).sorted('asset_depreciation_beginning_date', reverse=True) + if not current_and_previous_depreciation: + return 0 + + if len(current_and_previous_depreciation) > 1: + previous_value_residual = current_and_previous_depreciation[1].asset_remaining_value + else: + # If there is only one depreciation, we take the original depreciation value + previous_value_residual = self.original_value - self.salvage_value - self.already_depreciated_amount_import + + # We compare the amount_residuals of the depreciations before and during the given date. + # It applies the ratio of the period (to-given-date / total-days-of-the-period) to the amount of the depreciation. + cur_depr_end_date = self._get_end_period_date(date) + current_depreciation = current_and_previous_depreciation[0] + cur_depr_beg_date = current_depreciation.asset_depreciation_beginning_date + + rate = self._get_delta_days(cur_depr_beg_date, date) / self._get_delta_days(cur_depr_beg_date, cur_depr_end_date) + lost_value_at_date = (previous_value_residual - current_depreciation.asset_remaining_value) * rate + residual_value_at_date = self.currency_id.round(previous_value_residual - lost_value_at_date) + if self.currency_id.compare_amounts(self.original_value, 0) > 0: + return max(residual_value_at_date, 0) + else: + return min(residual_value_at_date, 0) + +class AccountAssetGroup(models.Model): + _name = 'account.asset.group' + _description = 'Asset Group' + _order = 'name' + + name = fields.Char("Name", index="trigram") + company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company) + linked_asset_ids = fields.One2many('account.asset', 'asset_group_id', string='Related Assets') + count_linked_assets = fields.Integer(compute='_compute_count_linked_asset') + + @api.depends('linked_asset_ids') + def _compute_count_linked_asset(self): + count_per_asset_group = { + asset_group.id: count + for asset_group, count in self.env['account.asset']._read_group( + domain=[ + ('asset_group_id', 'in', self.ids), + ], + groupby=['asset_group_id'], + aggregates=['__count'], + ) + } + for asset_group in self: + asset_group.count_linked_assets = count_per_asset_group.get(asset_group.id, 0) + + def action_open_linked_assets(self): + self.ensure_one() + return { + 'name': self.name, + 'view_mode': 'list,form', + 'res_model': 'account.asset', + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', self.linked_asset_ids.ids)], + } + +class AssetsReportCustomHandler(models.AbstractModel): + _name = 'account.asset.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Assets Report Custom Handler' + + def _get_custom_display_config(self): + return { + 'client_css_custom_class': 'depreciation_schedule', + 'templates': { + 'AccountReportFilters': 'at_accountingDepreciationScheduleFilters', + } + } + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + lines, totals_by_column_group = self._generate_report_lines_without_grouping(report, options) + # add the groups by grouping_field + if options['assets_grouping_field'] != 'none': + lines = self._group_by_field(report, lines, options) + else: + lines = report._regroup_lines_by_name_prefix(options, lines, '_report_expand_unfoldable_line_assets_report_prefix_group', 0) + + # add the total line + total_columns = [] + for column_data in options['columns']: + col_value = totals_by_column_group[column_data['column_group_key']].get(column_data['expression_label']) + col_value = col_value if column_data.get('figure_type') == 'monetary' else '' + + total_columns.append(report._build_column_dict(col_value, column_data, options=options)) + + if lines: + lines.append({ + 'id': report._get_generic_line_id(None, None, markup='total'), + 'level': 1, + 'name': _('Total'), + 'columns': total_columns, + 'unfoldable': False, + 'unfolded': False, + }) + + return [(0, line) for line in lines] + + def _generate_report_lines_without_grouping(self, report, options, prefix_to_match=None, parent_id=None, forced_account_id=None): + # construct a dictionary: + # {(account_id, asset_id, asset_group_id): {col_group_key: {expression_label_1: value, expression_label_2: value, ...}}} + all_asset_ids = set() + all_lines_data = {} + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + # the lines returned are already sorted by account_id! + lines_query_results = self._query_lines(column_group_options, prefix_to_match=prefix_to_match, forced_account_id=forced_account_id) + for account_id, asset_id, asset_group_id, cols_by_expr_label in lines_query_results: + line_id = (account_id, asset_id, asset_group_id) + all_asset_ids.add(asset_id) + if line_id not in all_lines_data: + all_lines_data[line_id] = {column_group_key: []} + all_lines_data[line_id][column_group_key] = cols_by_expr_label + + column_names = [ + 'assets_date_from', 'assets_plus', 'assets_minus', 'assets_date_to', 'depre_date_from', + 'depre_plus', 'depre_minus', 'depre_date_to', 'balance' + ] + totals_by_column_group = defaultdict(lambda: dict.fromkeys(column_names, 0.0)) + + # Browse all the necessary assets in one go, to minimize the number of queries + assets_cache = {asset.id: asset for asset in self.env['account.asset'].browse(all_asset_ids)} + + # construct the lines, 1 at a time + lines = [] + company_currency = self.env.company.currency_id + column_expression = self.env['account.report.expression'] + for (account_id, asset_id, asset_group_id), col_group_totals in all_lines_data.items(): + all_columns = [] + for column_data in options['columns']: + col_group_key = column_data['column_group_key'] + expr_label = column_data['expression_label'] + if col_group_key not in col_group_totals or expr_label not in col_group_totals[col_group_key]: + all_columns.append(report._build_column_dict(None, None)) + continue + + col_value = col_group_totals[col_group_key][expr_label] + col_data = None if col_value is None else column_data + + all_columns.append(report._build_column_dict(col_value, col_data, options=options, column_expression=column_expression, currency=company_currency)) + + # add to the total line + if column_data['figure_type'] == 'monetary': + totals_by_column_group[column_data['column_group_key']][column_data['expression_label']] += col_value + + name = assets_cache[asset_id].name + line = { + 'id': report._get_generic_line_id('account.asset', asset_id, parent_line_id=parent_id), + 'level': 2, + 'name': name, + 'columns': all_columns, + 'unfoldable': False, + 'unfolded': False, + 'caret_options': 'account_asset_line', + 'assets_account_id': account_id, + 'assets_asset_group_id': asset_group_id, + } + if parent_id: + line['parent_id'] = parent_id + if len(name) >= MAX_NAME_LENGTH: + line['title_hover'] = name + lines.append(line) + + return lines, totals_by_column_group + + def _caret_options_initializer(self): + # Use 'caret_option_open_record_form' defined in account_reports rather than a custom function + return { + 'account_asset_line': [ + {'name': _("Open Asset"), 'action': 'caret_option_open_record_form'}, + ] + } + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options=previous_options) + column_group_options_map = report._split_options_per_column_group(options) + + for col in options['columns']: + column_group_options = column_group_options_map[col['column_group_key']] + # Dynamic naming of columns containing dates + if col['expression_label'] == 'balance': + col['name'] = '' # The column label will be displayed in the subheader + if col['expression_label'] in ['assets_date_from', 'depre_date_from']: + col['name'] = format_date(self.env, column_group_options['date']['date_from']) + elif col['expression_label'] in ['assets_date_to', 'depre_date_to']: + col['name'] = format_date(self.env, column_group_options['date']['date_to']) + + options['custom_columns_subheaders'] = [ + {"name": _("Characteristics"), "colspan": 4}, + {"name": _("Assets"), "colspan": 4}, + {"name": _("Depreciation"), "colspan": 4}, + {"name": _("Book Value"), "colspan": 1} + ] + + # Group by account by default + options['assets_grouping_field'] = previous_options.get('assets_grouping_field') or 'account_id' + # If group by account is activated, activate the hierarchy (which will group by account group as well) if + # the company has at least one account group, otherwise only group by account + has_account_group = self.env['account.group'].search_count([('company_id', '=', self.env.company.id)], limit=1) + hierarchy_activated = previous_options.get('hierarchy', True) + options['hierarchy'] = has_account_group and hierarchy_activated or False + + def _query_lines(self, options, prefix_to_match=None, forced_account_id=None): + """ + Returns a list of tuples: [(asset_id, account_id, asset_group_id, [{expression_label: value}])] + """ + lines = [] + asset_lines = self._query_values(options, prefix_to_match=prefix_to_match, forced_account_id=forced_account_id) + + # Assign the gross increases sub assets to their main asset (parent) + parent_lines = [] + children_lines = defaultdict(list) + for al in asset_lines: + if al['parent_id']: + children_lines[al['parent_id']] += [al] + else: + parent_lines += [al] + + for al in parent_lines: + + asset_children_lines = children_lines[al['asset_id']] + asset_parent_values = self._get_parent_asset_values(options, al, asset_children_lines) + + # Format the data + columns_by_expr_label = { + "acquisition_date": al["asset_acquisition_date"] and format_date(self.env, al["asset_acquisition_date"]) or "", # Characteristics + "first_depreciation": al["asset_date"] and format_date(self.env, al["asset_date"]) or "", + "method": (al["asset_method"] == "linear" and _("Linear")) or (al["asset_method"] == "degressive" and _("Declining")) or _("Dec. then Straight"), + **asset_parent_values + } + + lines.append((al['account_id'], al['asset_id'], al['asset_group_id'], columns_by_expr_label)) + return lines + + def _get_parent_asset_values(self, options, asset_line, asset_children_lines): + """ Compute the values needed for the depreciation schedule for each parent asset + Overridden in l10n_ro_saft.account_general_ledger""" + + # Compute the depreciation rate string + if asset_line['asset_method'] == 'linear' and asset_line['asset_method_number']: # some assets might have 0 depreciation because they don't lose value + total_months = int(asset_line['asset_method_number']) * int(asset_line['asset_method_period']) + months = total_months % 12 + years = total_months // 12 + asset_depreciation_rate = " ".join(part for part in [ + years and _("%(years)s y", years=years), + months and _("%(months)s m", months=months), + ] if part) + elif asset_line['asset_method'] == 'linear': + asset_depreciation_rate = '0.00 %' + else: + asset_depreciation_rate = ('{:.2f} %').format(float(asset_line['asset_method_progress_factor']) * 100) + + # Manage the opening of the asset + opening = (asset_line['asset_acquisition_date'] or asset_line['asset_date']) < fields.Date.to_date(options['date']['date_from']) + + # Get the main values of the board for the asset + depreciation_opening = asset_line['depreciated_before'] + depreciation_add = asset_line['depreciated_during'] + depreciation_minus = 0.0 + + asset_disposal_value = ( + asset_line['asset_disposal_value'] + if ( + asset_line['asset_disposal_date'] + and asset_line['asset_disposal_date'] <= fields.Date.to_date(options['date']['date_to']) + ) + else 0.0 + ) + + asset_opening = asset_line['asset_original_value'] if opening else 0.0 + asset_add = 0.0 if opening else asset_line['asset_original_value'] + asset_minus = 0.0 + asset_salvage_value = asset_line.get('asset_salvage_value', 0.0) + + # Add the main values of the board for all the sub assets (gross increases) + for child in asset_children_lines: + depreciation_opening += child['depreciated_before'] + depreciation_add += child['depreciated_during'] + + opening = (child['asset_acquisition_date'] or child['asset_date']) < fields.Date.to_date(options['date']['date_from']) + asset_opening += child['asset_original_value'] if opening else 0.0 + asset_add += 0.0 if opening else child['asset_original_value'] + + # Compute the closing values + asset_closing = asset_opening + asset_add - asset_minus + depreciation_closing = depreciation_opening + depreciation_add - depreciation_minus + asset_currency = self.env['res.currency'].browse(asset_line['asset_currency_id']) + + # Manage the closing of the asset + if ( + asset_line['asset_state'] == 'close' + and asset_line['asset_disposal_date'] + and asset_line['asset_disposal_date'] <= fields.Date.to_date(options['date']['date_to']) + and asset_currency.compare_amounts(depreciation_closing, asset_closing - asset_salvage_value) == 0 + ): + depreciation_add -= asset_disposal_value + depreciation_minus += depreciation_closing - asset_disposal_value + depreciation_closing = 0.0 + asset_minus += asset_closing + asset_closing = 0.0 + + # Manage negative assets (credit notes) + if asset_currency.compare_amounts(asset_line['asset_original_value'], 0) < 0: + asset_add, asset_minus = -asset_minus, -asset_add + depreciation_add, depreciation_minus = -depreciation_minus, -depreciation_add + + return { + 'duration_rate': asset_depreciation_rate, + 'asset_disposal_value': asset_disposal_value, + 'assets_date_from': asset_opening, + 'assets_plus': asset_add, + 'assets_minus': asset_minus, + 'assets_date_to': asset_closing, + 'depre_date_from': depreciation_opening, + 'depre_plus': depreciation_add, + 'depre_minus': depreciation_minus, + 'depre_date_to': depreciation_closing, + 'balance': asset_closing - depreciation_closing, + } + + def _group_by_field(self, report, lines, options): + """ + This function adds the grouping lines on top of each group of account.asset + It iterates over the lines, change the line_id of each line to include the account.account.id and the + account.asset.id. + """ + if not lines: + return lines + + line_vals_per_grouping_field_id = {} + parent_model = 'account.account' if options['assets_grouping_field'] == 'account_id' else 'account.asset.group' + for line in lines: + parent_id = line.get('assets_account_id') if options['assets_grouping_field'] == 'account_id' else line.get('assets_asset_group_id') + + model, res_id = report._get_model_info_from_id(line['id']) + + # replace the line['id'] to add the parent id + line['id'] = report._build_line_id([ + (None, parent_model, parent_id), + (None, 'account.asset', res_id) + ]) + + is_parent_in_unfolded_lines = any( + report._get_model_info_from_id(unfolded_line_id) == (parent_model, parent_id) + for unfolded_line_id in options.get('unfolded_lines') + ) + line_vals_per_grouping_field_id.setdefault(parent_id, { + # We don't assign a name to the line yet, so that we can batch the browsing of the parent objects + 'id': report._build_line_id([(None, parent_model, parent_id)]), + 'columns': [], # Filled later + 'unfoldable': True, + 'unfolded': is_parent_in_unfolded_lines or options.get('unfold_all'), + 'level': 1, + + # This value is stored here for convenience; it will be removed from the result + 'group_lines': [], + })['group_lines'].append(line) + + # Generate the result + rslt_lines = [] + idx_monetary_columns = [idx_col for idx_col, col in enumerate(options['columns']) if col['figure_type'] == 'monetary'] + parent_recordset = self.env[parent_model].browse(line_vals_per_grouping_field_id.keys()) + + for parent_field in parent_recordset: + parent_line_vals = line_vals_per_grouping_field_id[parent_field.id] + if options['assets_grouping_field'] == 'account_id': + parent_line_vals['name'] = f"{parent_field.code} {parent_field.name}" + else: + parent_line_vals['name'] = parent_field.name or _('(No %s)', parent_field._description) + + rslt_lines.append(parent_line_vals) + + group_totals = {column_index: 0 for column_index in idx_monetary_columns} + group_lines = report._regroup_lines_by_name_prefix( + options, + parent_line_vals.pop('group_lines'), + '_report_expand_unfoldable_line_assets_report_prefix_group', + parent_line_vals['level'], + parent_line_dict_id=parent_line_vals['id'], + ) + + for parent_subline in group_lines: + # Add this line to the group totals + for column_index in idx_monetary_columns: + group_totals[column_index] += parent_subline['columns'][column_index].get('no_format', 0) + + # Setup the parent and add the line to the result + parent_subline['parent_id'] = parent_line_vals['id'] + rslt_lines.append(parent_subline) + + # Add totals (columns) to the parent line + for column_index in range(len(options['columns'])): + parent_line_vals['columns'].append(report._build_column_dict( + group_totals.get(column_index, ''), + options['columns'][column_index], + options=options, + )) + + return rslt_lines + + def _query_values(self, options, prefix_to_match=None, forced_account_id=None): + "Get the data from the database" + + self.env['account.move.line'].check_access('read') + self.env['account.asset'].check_access('read') + + query = Query(self.env, alias='asset', table=SQL.identifier('account_asset')) + account_alias = query.join(lhs_alias='asset', lhs_column='account_asset_id', rhs_table='account_account', rhs_column='id', link='account_asset_id') + query.add_join('LEFT JOIN', alias='move', table='account_move', condition=SQL(f""" + move.asset_id = asset.id AND move.state {"!= 'cancel'" if options.get('all_entries') else "= 'posted'"} + """)) + + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + account_name = self.env['account.account']._field_to_sql(account_alias, 'name') + account_id = SQL.identifier(account_alias, 'id') + + if prefix_to_match: + query.add_where(SQL("asset.name ILIKE %s", f"{prefix_to_match}%")) + if forced_account_id: + query.add_where(SQL("%s = %s", account_id, forced_account_id)) + + analytic_account_ids = [] + if options.get('analytic_accounts') and not any(x in options.get('analytic_accounts_list', []) for x in options['analytic_accounts']): + analytic_account_ids += [[str(account_id) for account_id in options['analytic_accounts']]] + if options.get('analytic_accounts_list'): + analytic_account_ids += [[str(account_id) for account_id in options.get('analytic_accounts_list')]] + if analytic_account_ids: + query.add_where(SQL('%s && %s', analytic_account_ids, self.env['account.asset']._query_analytic_accounts('asset'))) + + selected_journals = tuple(journal['id'] for journal in options.get('journals', []) if journal['model'] == 'account.journal' and journal['selected']) + if selected_journals: + query.add_where(SQL("asset.journal_id in %s", selected_journals)) + + sql = SQL( + """ + SELECT asset.id AS asset_id, + asset.parent_id AS parent_id, + asset.name AS asset_name, + asset.asset_group_id AS asset_group_id, + asset.original_value AS asset_original_value, + asset.currency_id AS asset_currency_id, + COALESCE(asset.salvage_value, 0) as asset_salvage_value, + MIN(move.date) AS asset_date, + asset.disposal_date AS asset_disposal_date, + asset.acquisition_date AS asset_acquisition_date, + asset.method AS asset_method, + asset.method_number AS asset_method_number, + asset.method_period AS asset_method_period, + asset.method_progress_factor AS asset_method_progress_factor, + asset.state AS asset_state, + asset.company_id AS company_id, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + %(account_id)s AS account_id, + COALESCE(SUM(move.depreciation_value) FILTER (WHERE move.date < %(date_from)s), 0) + COALESCE(asset.already_depreciated_amount_import, 0) AS depreciated_before, + COALESCE(SUM(move.depreciation_value) FILTER (WHERE move.date BETWEEN %(date_from)s AND %(date_to)s), 0) AS depreciated_during, + COALESCE(SUM(move.depreciation_value) FILTER (WHERE move.date BETWEEN %(date_from)s AND %(date_to)s AND move.asset_number_days IS NULL), 0) AS asset_disposal_value + FROM %(from_clause)s + WHERE %(where_clause)s + AND asset.company_id in %(company_ids)s + AND (asset.acquisition_date <= %(date_to)s OR move.date <= %(date_to)s) + AND (asset.disposal_date >= %(date_from)s OR asset.disposal_date IS NULL) + AND (asset.state not in ('model', 'draft', 'cancelled') OR (asset.state = 'draft' AND %(include_draft)s)) + AND asset.active = 't' + GROUP BY asset.id, account_id, account_code, account_name + ORDER BY account_code, asset.acquisition_date, asset.id; + """, + account_code=account_code, + account_name=account_name, + account_id=account_id, + date_from=options['date']['date_from'], + date_to=options['date']['date_to'], + from_clause=query.from_clause, + where_clause=query.where_clause or SQL('TRUE'), + company_ids=tuple(self.env['account.report'].get_report_company_ids(options)), + include_draft=options.get('all_entries', False), + ) + + self._cr.execute(sql) + results = self._cr.dictfetchall() + return results + + def _report_expand_unfoldable_line_assets_report_prefix_group(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + matched_prefix = self.env['account.report']._get_prefix_groups_matched_prefix_from_line_id(line_dict_id) + report = self.env['account.report'].browse(options['report_id']) + + lines, _totals_by_column_group = self._generate_report_lines_without_grouping( + report, + options, + prefix_to_match=matched_prefix, + parent_id=line_dict_id, + forced_account_id=self.env['account.report']._get_res_id_from_line_id(line_dict_id, 'account.account'), + ) + + lines = report._regroup_lines_by_name_prefix( + options, + lines, + '_report_expand_unfoldable_line_assets_report_prefix_group', + len(matched_prefix), + matched_prefix=matched_prefix, + parent_line_dict_id=line_dict_id, + ) + + return { + 'lines': lines, + 'offset_increment': len(lines), + 'has_more': False, + } + +class AssetsReport(models.Model): + _inherit = 'account.report' + + def _get_caret_option_view_map(self): + view_map = super()._get_caret_option_view_map() + view_map['account.asset.line'] = 'at_accountingview_account_asset_expense_form' + return view_map \ No newline at end of file