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

2192 lines
94 KiB
Python

"""
Fusion Accounting - Asset Management & Depreciation Module
Provides fixed asset tracking, automated depreciation scheduling,
and depreciation schedule reporting for Odoo 19.
"""
import datetime
import psycopg2
from collections import defaultdict
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 import format_date, SQL, Query
from odoo.tools.date_utils import end_of
# Normalized calendar constants used for prorata calculations
# when not using actual calendar days (constant_periods mode).
NORMALIZED_MONTH_DAYS = 30
NORMALIZED_YEAR_DAYS = NORMALIZED_MONTH_DAYS * 12
# Truncation limit for asset names displayed in report lines
REPORT_NAME_TRUNCATION = 50
class FusionAsset(models.Model):
"""
Manages fixed assets, deferred revenue recognition, and their
depreciation schedules throughout the asset lifecycle.
An asset transitions through: draft -> open (running) -> close/cancelled,
with an optional 'paused' state and a special 'model' state for templates.
"""
_name = 'account.asset'
_description = 'Asset/Revenue Recognition'
_inherit = ['mail.thread', 'mail.activity.mixin', 'analytic.mixin']
# -- Counters --
depreciation_entries_count = fields.Integer(
compute='_compute_counts',
string='# Posted Depreciation Entries',
)
gross_increase_count = fields.Integer(
compute='_compute_counts',
string='# Gross Increases',
help="Count of child assets created to augment this asset's value",
)
total_depreciation_entries_count = fields.Integer(
compute='_compute_counts',
string='# Depreciation Entries',
help="Total depreciation entries including both posted and draft",
)
# -- Identity --
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=(
"Draft: initial state for newly created assets.\n"
"Running: asset is confirmed and depreciation entries are generated.\n"
"On Hold: depreciation is temporarily suspended.\n"
"Closed: depreciation is complete or asset has been disposed.\n"
"Cancelled: all depreciation entries have been reversed."
),
)
active = fields.Boolean(default=True)
# -- Depreciation Parameters --
method = fields.Selection(
selection=[
('linear', 'Straight Line'),
('degressive', 'Declining'),
('degressive_then_linear', 'Declining then Straight Line'),
],
string='Method', default='linear',
help=(
"Straight Line: equal depreciation over each period based on gross value / duration.\n"
"Declining: each period's depreciation = remaining value * declining factor.\n"
"Declining then Straight Line: uses declining method but guarantees at least "
"the straight-line amount per period."
),
)
method_number = fields.Integer(
string='Duration', default=5,
help="Total number of depreciation periods over the asset lifetime",
)
method_period = fields.Selection(
[('1', 'Months'), ('12', 'Years')],
string='Number of Months in a Period', default='12',
help="Length of each depreciation period in months",
)
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='Reference start date for the prorata computation of the first depreciation period',
required=True, precompute=True, copy=True,
)
paused_prorata_date = fields.Date(
compute='_compute_paused_prorata_date',
help="Prorata date adjusted forward by the number of paused days",
)
# -- Accounts --
account_asset_id = fields.Many2one(
'account.account', string='Fixed Asset Account',
compute='_compute_account_asset_id',
help="Account recording the original purchase price of the asset.",
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')), "
"]",
help="Account for accumulated depreciation entries reducing 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')), "
"]",
help="Account debited for periodic depreciation expense recognition.",
)
journal_id = fields.Many2one(
'account.journal', string='Journal', check_company=True,
domain="[('type', '=', 'general')]",
compute='_compute_journal_id', store=True, readonly=False,
)
# -- Monetary 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="Net book value: residual depreciable amount + salvage value + child asset book values",
)
value_residual = fields.Monetary(
string='Depreciable Value',
compute='_compute_value_residual',
)
salvage_value = fields.Monetary(
string='Not Depreciable Value',
help="Estimated residual value at end of useful life that will not be depreciated.",
compute="_compute_salvage_value", store=True, readonly=False,
)
salvage_value_pct = fields.Float(
string='Not Depreciable Value Percent',
help="Percentage of original value to retain as salvage value.",
)
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')
# -- Entry Links --
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,
)
# -- Properties --
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 / Template Reference --
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")
# -- Parent / Child (Gross Increases) --
parent_id = fields.Many2one(
'account.asset',
help="Parent asset when this asset represents a gross increase in value",
)
children_ids = fields.One2many(
'account.asset', 'parent_id',
help="Child assets representing value increases for this asset",
)
# -- Import Support --
already_depreciated_amount_import = fields.Monetary(
help=(
"When importing from external software, set this field to align the "
"depreciation schedule with previously recorded depreciation amounts."
),
)
# -- Lifetime Tracking --
asset_lifetime_days = fields.Float(
compute="_compute_lifetime_days", recursive=True,
help="Total normalized days for the asset's depreciation schedule",
)
asset_paused_days = fields.Float(copy=False)
net_gain_on_sale = fields.Monetary(
string="Net gain on sale",
help="Net gain or loss realized upon sale/disposal of the asset",
copy=False,
)
# -- Linked Assets --
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")
# =========================================================================
# COMPUTED FIELDS
# =========================================================================
@api.depends('company_id')
def _compute_journal_id(self):
"""Select a general journal matching the asset's company."""
for rec in self:
if rec.journal_id and rec.journal_id.company_id == rec.company_id:
continue
rec.journal_id = self.env['account.journal'].search([
*self.env['account.journal']._check_company_domain(rec.company_id),
('type', '=', 'general'),
], limit=1)
@api.depends('salvage_value', 'original_value')
def _compute_total_depreciable_value(self):
"""Net amount subject to depreciation = original minus salvage."""
for rec in self:
rec.total_depreciable_value = rec.original_value - rec.salvage_value
@api.depends('original_value', 'model_id')
def _compute_salvage_value(self):
"""Derive salvage value from model percentage if configured."""
for rec in self:
pct = rec.model_id.salvage_value_pct
if pct != 0.0:
rec.salvage_value = rec.original_value * pct
@api.depends('depreciation_move_ids.date', 'state')
def _compute_disposal_date(self):
"""Set disposal date to the latest depreciation entry date when closed."""
for rec in self:
if rec.state == 'close':
entry_dates = rec.depreciation_move_ids.filtered(
lambda mv: mv.date
).mapped('date')
rec.disposal_date = max(entry_dates) if entry_dates else False
else:
rec.disposal_date = False
@api.depends('original_move_line_ids', 'original_move_line_ids.account_id', 'non_deductible_tax_value')
def _compute_value(self):
"""Compute original value from linked purchase journal items."""
for rec in self:
if not rec.original_move_line_ids:
rec.original_value = rec.original_value or False
continue
draft_lines = rec.original_move_line_ids.filtered(
lambda ln: ln.move_id.state == 'draft'
)
if draft_lines:
raise UserError(_("All the lines should be posted"))
computed_val = rec.related_purchase_value
if rec.non_deductible_tax_value:
computed_val += rec.non_deductible_tax_value
rec.original_value = computed_val
@api.depends('original_move_line_ids')
@api.depends_context('form_view_ref')
def _compute_display_account_asset_id(self):
"""Hide the asset account field when creating a model from the Chart of Accounts."""
for rec in self:
creating_model_from_coa = (
self.env.context.get('form_view_ref') and rec.state == 'model'
)
rec.display_account_asset_id = (
not rec.original_move_line_ids and not creating_model_from_coa
)
@api.depends('account_depreciation_id', 'account_depreciation_expense_id', 'original_move_line_ids')
def _compute_account_asset_id(self):
"""Derive asset account from linked journal items when available."""
for rec in self:
if rec.original_move_line_ids:
distinct_accounts = rec.original_move_line_ids.account_id
if len(distinct_accounts) > 1:
raise UserError(_("All the lines should be from the same account"))
rec.account_asset_id = distinct_accounts
if not rec.account_asset_id:
rec._onchange_account_depreciation_id()
@api.depends('original_move_line_ids')
def _compute_analytic_distribution(self):
"""Blend analytic distributions from source journal items weighted by balance."""
for rec in self:
blended = {}
total_bal = sum(rec.original_move_line_ids.mapped("balance"))
if not float_is_zero(total_bal, precision_rounding=rec.currency_id.rounding):
for line in rec.original_move_line_ids._origin:
if line.analytic_distribution:
for acct_key, pct in line.analytic_distribution.items():
blended[acct_key] = blended.get(acct_key, 0) + pct * line.balance
for acct_key in blended:
blended[acct_key] /= total_bal
rec.analytic_distribution = blended if blended else rec.analytic_distribution
@api.depends('method_number', 'method_period', 'prorata_computation_type')
def _compute_lifetime_days(self):
"""
Calculate the total normalized lifetime in days for the depreciation schedule.
Child assets inherit the remaining lifetime of their parent.
"""
for rec in self:
period_months = int(rec.method_period)
total_periods = rec.method_number
if not rec.parent_id:
if rec.prorata_computation_type == 'daily_computation':
# Use actual calendar day count
end_dt = rec.prorata_date + relativedelta(months=period_months * total_periods)
rec.asset_lifetime_days = (end_dt - rec.prorata_date).days
else:
# Use normalized 30-day months
rec.asset_lifetime_days = period_months * total_periods * NORMALIZED_MONTH_DAYS
else:
# Child inherits remaining lifetime of parent asset
parent = rec.parent_id
if rec.prorata_computation_type == 'daily_computation':
parent_terminus = parent.paused_prorata_date + relativedelta(
days=int(parent.asset_lifetime_days - 1)
)
else:
full_months = int(parent.asset_lifetime_days / NORMALIZED_MONTH_DAYS)
leftover_days = int(parent.asset_lifetime_days % NORMALIZED_MONTH_DAYS)
parent_terminus = parent.paused_prorata_date + relativedelta(
months=full_months, days=leftover_days - 1
)
rec.asset_lifetime_days = rec._compute_day_span(rec.prorata_date, parent_terminus)
@api.depends('acquisition_date', 'company_id', 'prorata_computation_type')
def _compute_prorata_date(self):
"""
Set the prorata reference date. For 'No Prorata' mode, align to the
start of the fiscal year containing the acquisition date.
"""
for rec in self:
if rec.prorata_computation_type == 'none' and rec.acquisition_date:
fy_info = rec.company_id.compute_fiscalyear_dates(rec.acquisition_date)
rec.prorata_date = fy_info.get('date_from')
else:
rec.prorata_date = rec.acquisition_date
@api.depends('prorata_date', 'prorata_computation_type', 'asset_paused_days')
def _compute_paused_prorata_date(self):
"""Shift the prorata date forward by the accumulated pause duration."""
for rec in self:
pause_days = rec.asset_paused_days
if rec.prorata_computation_type == 'daily_computation':
rec.paused_prorata_date = rec.prorata_date + relativedelta(days=pause_days)
else:
whole_months = int(pause_days / NORMALIZED_MONTH_DAYS)
remainder_days = pause_days % NORMALIZED_MONTH_DAYS
rec.paused_prorata_date = rec.prorata_date + relativedelta(
months=whole_months, days=remainder_days
)
@api.depends('original_move_line_ids')
def _compute_related_purchase_value(self):
"""Sum balances from linked purchase journal items, adjusting for multi-asset lines."""
for rec in self:
purchase_total = sum(rec.original_move_line_ids.mapped('balance'))
if (
rec.account_asset_id.multiple_assets_per_line
and len(rec.original_move_line_ids) == 1
):
qty = max(1, int(rec.original_move_line_ids.quantity))
purchase_total /= qty
rec.related_purchase_value = purchase_total
@api.depends('original_move_line_ids')
def _compute_acquisition_date(self):
"""Derive acquisition date from the earliest linked journal item."""
for rec in self:
if rec.acquisition_date:
continue
candidate_dates = [
(aml.invoice_date or aml.date) for aml in rec.original_move_line_ids
]
candidate_dates.append(fields.Date.today())
rec.acquisition_date = min(candidate_dates)
@api.depends('original_move_line_ids')
def _compute_name(self):
"""Default name from the first linked journal item's label."""
for rec in self:
if not rec.name and rec.original_move_line_ids:
rec.name = rec.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):
"""Calculate remaining depreciable value after posted depreciation entries."""
for rec in self:
posted_depr = rec.depreciation_move_ids.filtered(lambda mv: mv.state == 'posted')
total_posted = sum(posted_depr.mapped('depreciation_value'))
rec.value_residual = (
rec.original_value
- rec.salvage_value
- rec.already_depreciated_amount_import
- total_posted
)
@api.depends('value_residual', 'salvage_value', 'children_ids.book_value')
def _compute_book_value(self):
"""
Net book value includes residual depreciable amount, salvage value,
and the book values of any gross increase child assets.
When fully closed, the salvage value is excluded.
"""
for rec in self:
children_bv = sum(rec.children_ids.mapped('book_value'))
rec.book_value = rec.value_residual + rec.salvage_value + children_bv
if rec.state == 'close':
all_posted = all(mv.state == 'posted' for mv in rec.depreciation_move_ids)
if all_posted:
rec.book_value -= rec.salvage_value
@api.depends('children_ids.original_value')
def _compute_gross_increase_value(self):
"""Total original value from all gross increase child assets."""
for rec in self:
rec.gross_increase_value = sum(rec.children_ids.mapped('original_value'))
@api.depends('original_move_line_ids')
def _compute_non_deductible_tax_value(self):
"""Sum non-deductible tax amounts from source journal items."""
for rec in self:
ndt_total = 0.0
for line in rec.original_move_line_ids:
if line.non_deductible_tax_value:
acct = line.account_id
multi_asset = acct.create_asset != 'no' and acct.multiple_assets_per_line
divisor = line.quantity if multi_asset else 1
converted = line.currency_id._convert(
line.non_deductible_tax_value / divisor,
rec.currency_id, rec.company_id, line.date,
)
ndt_total += rec.currency_id.round(converted)
rec.non_deductible_tax_value = ndt_total
@api.depends('depreciation_move_ids.state', 'parent_id')
def _compute_counts(self):
"""Compute posted depreciation count, total entry count, and gross increase count."""
posted_counts = {
group.id: cnt
for group, cnt in self.env['account.move']._read_group(
domain=[
('asset_id', 'in', self.ids),
('state', '=', 'posted'),
],
groupby=['asset_id'],
aggregates=['__count'],
)
}
for rec in self:
rec.depreciation_entries_count = posted_counts.get(rec.id, 0)
rec.total_depreciation_entries_count = len(rec.depreciation_move_ids)
rec.gross_increase_count = len(rec.children_ids)
@api.depends('original_move_line_ids.asset_ids')
def _compute_linked_assets(self):
"""Find other assets sharing the same source journal items."""
for rec in self:
rec.linked_assets_ids = rec.original_move_line_ids.asset_ids - self
rec.count_linked_asset = len(rec.linked_assets_ids)
running_linked = rec.linked_assets_ids.filtered(lambda a: a.state == 'open')
# Flag the smart button in red when at least one linked asset is confirmed
rec.warning_count_assets = len(running_linked) > 0
# =========================================================================
# ONCHANGE HANDLERS
# =========================================================================
@api.onchange('account_depreciation_id')
def _onchange_account_depreciation_id(self):
"""Default the asset account to the depreciation account if not yet set."""
if not self.original_move_line_ids:
if not self.account_asset_id and self.state != 'model':
self.account_asset_id = self.account_depreciation_id
@api.onchange('original_value', 'original_move_line_ids')
def _display_original_value_warning(self):
"""Warn when the entered original value diverges from the linked purchase total."""
if self.original_move_line_ids:
expected = self.related_purchase_value + self.non_deductible_tax_value
if self.original_value != expected:
return {
'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, expected, currency_obj=self.currency_id),
),
}
}
@api.onchange('original_move_line_ids')
def _onchange_original_move_line_ids(self):
"""Recompute acquisition date when source journal items change."""
self.acquisition_date = False
self._compute_acquisition_date()
@api.onchange('account_asset_id')
def _onchange_account_asset_id(self):
"""Mirror asset account to depreciation account as a default."""
self.account_depreciation_id = self.account_depreciation_id or self.account_asset_id
@api.onchange('model_id')
def _onchange_model_id(self):
"""Apply all depreciation parameters from the selected template model."""
tmpl = self.model_id
if tmpl:
self.method = tmpl.method
self.method_number = tmpl.method_number
self.method_period = tmpl.method_period
self.method_progress_factor = tmpl.method_progress_factor
self.prorata_computation_type = tmpl.prorata_computation_type
self.analytic_distribution = tmpl.analytic_distribution or self.analytic_distribution
self.account_asset_id = tmpl.account_asset_id
self.account_depreciation_id = tmpl.account_depreciation_id
self.account_depreciation_expense_id = tmpl.account_depreciation_expense_id
self.journal_id = tmpl.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):
"""
Clear existing depreciation entries when core parameters change,
preventing stale data from lingering on the board.
"""
self.write({'depreciation_move_ids': [Command.set([])]})
# =========================================================================
# CONSTRAINTS
# =========================================================================
@api.constrains('active', 'state')
def _check_active(self):
for rec in self:
if not rec.active and rec.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):
"""Ensure the final depreciation entry exhausts the depreciable value."""
for rec in self:
if rec.state != 'open' or not rec.depreciation_move_ids:
continue
last_entry = rec.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id))[-1]
if not rec.currency_id.is_zero(last_entry.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):
"""Validate that linked purchase lines produce a non-zero value."""
for rec in self:
if rec.original_move_line_ids and rec.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 rec.state not in ('model', 'draft'):
raise UserError(_(
"You cannot add or remove bills when the asset is already running or closed."
))
# =========================================================================
# CRUD OVERRIDES
# =========================================================================
@api.ondelete(at_uninstall=True)
def _unlink_if_model_or_draft(self):
"""Prevent deletion of active or paused assets."""
for rec in self:
if rec.state in ('open', 'paused', 'close'):
state_label = dict(
self._fields['state']._description_selection(self.env)
).get(rec.state)
raise UserError(_(
'You cannot delete a document that is in %s state.', state_label
))
posted_count = len(
rec.depreciation_move_ids.filtered(lambda mv: mv.state == 'posted')
)
if posted_count > 0:
raise UserError(_(
'You cannot delete an asset linked to posted entries.\n'
'You should either confirm the asset, then, sell or dispose of it,'
' or cancel the linked journal entries.'
))
def unlink(self):
"""Post deletion notices on linked source journal entries."""
for rec in self:
for line in rec.original_move_line_ids:
if line.name:
notice = _(
'A document linked to %(move_line_name)s has been deleted: %(link)s',
move_line_name=line.name,
link=rec._get_html_link(),
)
else:
notice = _(
'A document linked to this move has been deleted: %s',
rec._get_html_link(),
)
line.move_id.message_post(body=notice)
if len(line.move_id.asset_ids) == 1:
line.move_id.asset_move_type = False
return super(FusionAsset, self).unlink()
def copy_data(self, default=None):
"""Preserve model state and append (copy) suffix when duplicating."""
result = super().copy_data(default)
for rec, vals in zip(self, result):
if rec.state == 'model':
vals['state'] = 'model'
vals['name'] = _('%s (copy)', rec.name)
vals['account_asset_id'] = rec.account_asset_id.id
return result
@api.model_create_multi
def create(self, vals_list):
"""Create assets, enforcing draft state for non-model records."""
for vals in vals_list:
if (
'state' in vals
and vals['state'] != 'draft'
and not (set(vals) - {'account_depreciation_id', 'account_depreciation_expense_id', 'journal_id'})
):
raise UserError(_("Some required values are missing"))
is_model_context = self.env.context.get('default_state') == 'model'
if not is_model_context and vals.get('state') != 'model':
vals['state'] = 'draft'
new_records = super(FusionAsset, self.with_context(mail_create_nolog=True)).create(vals_list)
# Respect explicit original_value even if compute set a different value
for idx, vals in enumerate(vals_list):
if 'original_value' in vals:
new_records[idx].original_value = vals['original_value']
if self.env.context.get('original_asset'):
source_asset = self.env['account.asset'].browse(
self.env.context['original_asset']
)
source_asset.model_id = new_records
return new_records
def write(self, vals):
"""Propagate account/journal changes to draft depreciation entries."""
result = super().write(vals)
for rec in self:
for entry in rec.depreciation_move_ids:
if entry.state == 'draft' and 'analytic_distribution' in vals:
entry.line_ids.analytic_distribution = vals['analytic_distribution']
fiscal_lock = entry.company_id._get_user_fiscal_lock_date(rec.journal_id)
if entry.date > fiscal_lock:
if 'account_depreciation_id' in vals:
# Even-indexed lines (0, 2, 4...) use the depreciation account
entry.line_ids[::2].account_id = vals['account_depreciation_id']
if 'account_depreciation_expense_id' in vals:
# Odd-indexed lines (1, 3, 5...) use the expense account
entry.line_ids[1::2].account_id = vals['account_depreciation_expense_id']
if 'journal_id' in vals:
entry.journal_id = vals['journal_id']
return result
# =========================================================================
# DEPRECIATION BOARD COMPUTATION
# =========================================================================
def _compute_straight_line_amount(self, elapsed_before, elapsed_through_end, depreciable_base):
"""
Calculate linear depreciation for a period by computing the expected
cumulative depreciation at period boundaries and taking the difference.
Accounts for value-change entries that modify the schedule mid-life.
"""
expected_at_start = depreciable_base * elapsed_before / self.asset_lifetime_days
expected_at_end = depreciable_base * elapsed_through_end / self.asset_lifetime_days
# Reduce for any mid-life value decreases spread over remaining life
change_entries = self.depreciation_move_ids.filtered(lambda mv: mv.asset_value_change)
period_length = elapsed_through_end - elapsed_before
adjustment_total = sum(
period_length * mv.depreciation_value / (
self.asset_lifetime_days
- self._compute_day_span(self.paused_prorata_date, mv.asset_depreciation_beginning_date)
)
for mv in change_entries
)
raw_amount = expected_at_end - self.currency_id.round(expected_at_start) - adjustment_total
return self.currency_id.round(raw_amount)
def _compute_board_amount(
self, remaining_value, period_start, period_end,
days_already_depreciated, days_remaining_in_life,
declining_base, fiscal_year_start=None,
total_life_remaining=None, residual_at_recompute=None,
recompute_start_date=None,
):
"""
Determine the depreciation amount for a single period based on the
configured method (linear, degressive, or degressive-then-linear).
Returns (period_day_count, rounded_amount).
"""
def _pick_larger_of_declining_and_linear(straight_line_val, fy_start=fiscal_year_start):
"""
For degressive: compute the declining-balance amount for this period
and return whichever is larger (in absolute terms) vs. the straight-line amount.
"""
fy_dates = self.company_id.compute_fiscalyear_dates(period_end)
fy_day_count = self._compute_day_span(fy_dates['date_from'], fy_dates['date_to'])
elapsed_in_fy = self._compute_day_span(fy_start, period_end)
declining_target = declining_base * (1 - self.method_progress_factor * elapsed_in_fy / fy_day_count)
declining_period = remaining_value - declining_target
return self._select_dominant_amount(remaining_value, declining_period, straight_line_val)
if float_is_zero(self.asset_lifetime_days, 2) or float_is_zero(remaining_value, 2):
return 0, 0
elapsed_through_end = self._compute_day_span(self.paused_prorata_date, period_end)
elapsed_before = self._compute_day_span(
self.paused_prorata_date,
period_start + relativedelta(days=-1),
)
elapsed_before = max(elapsed_before, 0)
period_day_count = elapsed_through_end - elapsed_before
if self.method == 'linear':
if total_life_remaining and float_compare(total_life_remaining, 0, 2) > 0:
elapsed_since_recompute = self._compute_day_span(recompute_start_date, period_end)
proportion_consumed = elapsed_since_recompute / total_life_remaining
amount = remaining_value - residual_at_recompute * (1 - proportion_consumed)
else:
amount = self._compute_straight_line_amount(
elapsed_before, elapsed_through_end, self.total_depreciable_value
)
amount = min(amount, remaining_value, key=abs)
elif self.method == 'degressive':
eff_start = (
max(fiscal_year_start, self.paused_prorata_date)
if fiscal_year_start
else self.paused_prorata_date
)
span_from_fy_start = (
self._compute_day_span(eff_start, period_start - relativedelta(days=1))
+ days_remaining_in_life
)
linear_target = (
declining_base
- declining_base * self._compute_day_span(eff_start, period_end) / span_from_fy_start
)
straight_line_val = remaining_value - linear_target
amount = _pick_larger_of_declining_and_linear(straight_line_val, eff_start)
elif self.method == 'degressive_then_linear':
if not self.parent_id:
straight_line_val = self._compute_straight_line_amount(
elapsed_before, elapsed_through_end, self.total_depreciable_value
)
else:
# Align child asset's transition point with the parent's schedule
parent = self.parent_id
parent_prior_entries = parent.depreciation_move_ids.filtered(
lambda mv: mv.date <= self.prorata_date
).sorted(key=lambda mv: (mv.date, mv.id))
if parent_prior_entries:
parent_cumul = parent_prior_entries[-1].asset_depreciated_value
parent_remaining = parent_prior_entries[-1].asset_remaining_value
else:
parent_cumul = parent.already_depreciated_amount_import
parent_remaining = parent.total_depreciable_value
if self.currency_id.is_zero(parent_remaining):
straight_line_val = self._compute_straight_line_amount(
elapsed_before, elapsed_through_end, self.total_depreciable_value
)
else:
# Scale the child's depreciable base to match parent's curve
scale_factor = 1 + parent_cumul / parent_remaining
scaled_base = self.total_depreciable_value * scale_factor
lifetime_ratio = self.asset_lifetime_days / parent.asset_lifetime_days
straight_line_val = (
self._compute_straight_line_amount(
elapsed_before, elapsed_through_end, scaled_base
) * lifetime_ratio
)
amount = _pick_larger_of_declining_and_linear(straight_line_val)
# Clamp sign to match residual direction
if self.currency_id.compare_amounts(remaining_value, 0) > 0:
amount = max(amount, 0)
else:
amount = min(amount, 0)
amount = self._clamp_final_period(remaining_value, amount, elapsed_through_end)
return period_day_count, self.currency_id.round(amount)
def compute_depreciation_board(self, date=False):
"""
Regenerate the full depreciation schedule. Draft entries at or after
the given date are removed, then new entries are computed and created.
"""
self.depreciation_move_ids.filtered(
lambda mv: mv.state == 'draft' and (mv.date >= date if date else True)
).unlink()
all_new_vals = []
for rec in self:
all_new_vals.extend(rec._recompute_board(date))
created_moves = self.env['account.move'].create(all_new_vals)
# Post entries for running assets; future entries remain as auto-post drafts
to_post = created_moves.filtered(lambda mv: mv.asset_id.state == 'open')
to_post._post()
def _recompute_board(self, start_date_override=False):
"""
Build the list of depreciation move value dicts for a single asset,
starting from the given date or the paused prorata date.
"""
self.ensure_one()
posted_entries = self.depreciation_move_ids.filtered(
lambda mv: mv.state == 'posted' and not mv.asset_value_change
).sorted(key=lambda mv: (mv.date, mv.id))
prior_import = self.already_depreciated_amount_import
draft_total = sum(
self.depreciation_move_ids.filtered(lambda mv: mv.state == 'draft')
.mapped('depreciation_value')
)
remaining = self.value_residual - draft_total
if not posted_entries:
remaining += prior_import
declining_base = recompute_residual = remaining
cursor_date = start_date_override or self.paused_prorata_date
recompute_origin = cursor_date
fy_period_start = cursor_date
terminus = self._get_last_day_asset()
final_period_end = self._get_end_period_date(terminus)
total_life_left = self._compute_day_span(cursor_date, terminus)
move_vals_list = []
if not float_is_zero(self.value_residual, precision_rounding=self.currency_id.rounding):
while not self.currency_id.is_zero(remaining) and cursor_date < final_period_end:
period_end = self._get_end_period_date(cursor_date)
fy_end = self.company_id.compute_fiscalyear_dates(period_end).get('date_to')
life_left = self._compute_day_span(cursor_date, terminus)
day_count, depr_amount = self._compute_board_amount(
remaining, cursor_date, period_end,
False, life_left, declining_base,
fy_period_start, total_life_left,
recompute_residual, recompute_origin,
)
remaining -= depr_amount
# Handle pre-imported depreciation by absorbing it from initial entries
if not posted_entries:
if abs(prior_import) <= abs(depr_amount):
depr_amount -= prior_import
prior_import = 0
else:
prior_import -= depr_amount
depr_amount = 0
if (
self.method == 'degressive_then_linear'
and final_period_end < period_end
):
period_end = final_period_end
if not float_is_zero(depr_amount, precision_rounding=self.currency_id.rounding):
move_vals_list.append(
self.env['account.move']._prepare_move_for_asset_depreciation({
'amount': depr_amount,
'asset_id': self,
'depreciation_beginning_date': cursor_date,
'date': period_end,
'asset_number_days': day_count,
})
)
# Reset declining base at fiscal year boundaries
if period_end == fy_end:
next_fy = self.company_id.compute_fiscalyear_dates(period_end)
fy_period_start = next_fy.get('date_from') + relativedelta(years=1)
declining_base = remaining
cursor_date = period_end + relativedelta(days=1)
return move_vals_list
def _get_end_period_date(self, ref_date):
"""
Determine the end of the depreciation period containing ref_date.
For monthly periods, this is the month-end; for yearly, the fiscal year end.
"""
self.ensure_one()
fy_end = self.company_id.compute_fiscalyear_dates(ref_date).get('date_to')
period_end = fy_end if ref_date <= fy_end else fy_end + relativedelta(years=1)
if self.method_period == '1':
month_last = end_of(
datetime.date(ref_date.year, ref_date.month, 1), 'month'
).day
period_end = min(ref_date.replace(day=month_last), period_end)
return period_end
def _compute_day_span(self, dt_start, dt_end):
"""
Count the number of days between two dates, using either actual
calendar days (daily_computation) or normalized 30-day months.
"""
self.ensure_one()
if self.prorata_computation_type == 'daily_computation':
return (dt_end - dt_start).days + 1
# Normalized calculation: each month counts as exactly 30 days,
# with prorated fractions for partial months at start and end.
actual_days_in_start_month = end_of(dt_start, 'month').day
# Fraction of the starting month from dt_start through month-end
start_fraction = (actual_days_in_start_month - dt_start.day + 1) / actual_days_in_start_month
actual_days_in_end_month = end_of(dt_end, 'month').day
# Fraction of the ending month from month-start through dt_end
end_fraction = dt_end.day / actual_days_in_end_month
# Full years between the two dates (in normalized days)
year_span = (dt_end.year - dt_start.year) * NORMALIZED_YEAR_DAYS
# Full months between the partial start and end months
month_span = (dt_end.month - dt_start.month - 1) * NORMALIZED_MONTH_DAYS
return (
start_fraction * NORMALIZED_MONTH_DAYS
+ end_fraction * NORMALIZED_MONTH_DAYS
+ year_span
+ month_span
)
def _get_last_day_asset(self):
"""Get the final calendar day of the asset's depreciation schedule."""
ref = self.parent_id if self.parent_id else self
period_months = int(ref.method_period)
return ref.paused_prorata_date + relativedelta(
months=period_months * ref.method_number, days=-1
)
# =========================================================================
# PUBLIC ACTIONS
# =========================================================================
def action_open_linked_assets(self):
"""Open a view showing assets that share the same source journal items."""
action = self.linked_assets_ids.open_asset(['list', 'form'])
action.get('context', {}).update({'from_linked_assets': 0})
return action
def action_asset_modify(self):
"""Open the asset modification wizard for disposal or resumption."""
self.ensure_one()
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': wizard.id,
'context': self.env.context,
}
def action_save_model(self):
"""Save the current asset's configuration as a reusable model/template."""
return {
'name': _('Save model'),
'views': [[self.env.ref('fusion_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):
"""Open list/form view of all depreciation journal entries."""
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.env.context, create=False),
}
def open_related_entries(self):
"""Open the source purchase journal items linked to this asset."""
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):
"""Open list/form view of gross increase child assets."""
action = {
'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:
action['views'] = [(False, 'form')]
action['res_id'] = self.children_ids.id
return action
def open_parent_id(self):
"""Navigate to the parent asset form view."""
return {
'name': _('Parent Asset'),
'view_mode': 'form',
'res_model': 'account.asset',
'type': 'ir.actions.act_window',
'res_id': self.parent_id.id,
'views': [(False, 'form')],
}
def validate(self):
"""
Confirm draft assets: transition to 'open' state, compute depreciation
board if needed, and post all entries.
"""
tracked_field_names = [
'method', 'method_number', 'method_period',
'method_progress_factor', 'salvage_value', 'original_move_line_ids',
]
field_definitions = self.env['account.asset'].fields_get(tracked_field_names)
self.write({'state': 'open'})
for rec in self:
active_fields = field_definitions.copy()
if rec.method == 'linear':
del active_fields['method_progress_factor']
_dummy, tracking_ids = rec._mail_track(
active_fields, dict.fromkeys(tracked_field_names)
)
creation_label = _('Asset created')
chatter_msg = _('An asset has been created for this move:') + ' ' + rec._get_html_link()
rec.message_post(body=creation_label, tracking_value_ids=tracking_ids)
for source_move in rec.original_move_line_ids.mapped('move_id'):
source_move.message_post(body=chatter_msg)
try:
if not rec.depreciation_move_ids:
rec.compute_depreciation_board()
rec._check_depreciations()
rec.depreciation_move_ids.filtered(
lambda mv: mv.state != 'posted'
)._post()
except psycopg2.errors.CheckViolation:
raise ValidationError(_(
"At least one asset (%s) couldn't be set as running "
"because it lacks any required information", rec.name
))
if rec.account_asset_id.create_asset == 'no':
rec._post_non_deductible_tax_value()
def set_to_close(self, invoice_line_ids, date=None, message=None):
"""
Close the asset (and its gross increases), generating disposal moves
and computing net gain/loss on sale.
"""
self.ensure_one()
close_date = date or fields.Date.today()
fiscal_lock = self.company_id._get_user_fiscal_lock_date(self.journal_id)
if close_date <= fiscal_lock:
raise UserError(_("You cannot dispose of an asset before the lock date."))
if invoice_line_ids:
active_children = self.children_ids.filtered(
lambda c: c.state in ('draft', 'open') or c.value_residual > 0
)
if active_children:
raise UserError(_(
"You cannot automate the journal entry for an asset that has a "
"running gross increase. Please use 'Dispose' on the increase(s)."
))
combined = self + self.children_ids
combined.state = 'close'
disposal_move_ids = combined._get_disposal_moves(
[invoice_line_ids] * len(combined), close_date
)
for rec in combined:
body = (
_('Asset sold. %s', message or "")
if invoice_line_ids
else _('Asset disposed. %s', message or "")
)
rec.message_post(body=body)
sale_proceeds = abs(sum(ln.balance for ln in invoice_line_ids))
self.net_gain_on_sale = self.currency_id.round(sale_proceeds - self.book_value)
if disposal_move_ids:
label = _('Disposal Move') if len(disposal_move_ids) == 1 else _('Disposal Moves')
view = 'form' if len(disposal_move_ids) == 1 else 'list,form'
return {
'name': label,
'view_mode': view,
'res_model': 'account.move',
'type': 'ir.actions.act_window',
'target': 'current',
'res_id': disposal_move_ids[0],
'domain': [('id', 'in', disposal_move_ids)],
}
def set_to_cancelled(self):
"""
Cancel the asset: reverse all posted depreciation entries, remove
drafts, and log a detailed summary of reversed amounts.
"""
for rec in self:
unreversed_posted = rec.depreciation_move_ids.filtered(lambda mv: (
not mv.reversal_move_ids
and not mv.reversed_entry_id
and mv.state == 'posted'
))
if unreversed_posted:
expense_delta = sum(unreversed_posted.line_ids.mapped(
lambda ln: ln.debit if ln.account_id == rec.account_depreciation_expense_id else 0.0
))
accumulated_delta = sum(unreversed_posted.line_ids.mapped(
lambda ln: ln.credit if ln.account_id == rec.account_depreciation_id else 0.0
))
entry_descriptions = Markup('<br>').join(
unreversed_posted.sorted('date').mapped(lambda mv: (
f'{mv.ref} - {mv.date} - '
f'{formatLang(self.env, mv.depreciation_value, currency_obj=mv.currency_id)} - '
f'{mv.name}'
))
)
rec._cancel_future_moves(datetime.date.min)
summary = (
_('Asset Cancelled')
+ Markup('<br>')
+ _(
'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=rec.account_depreciation_expense_id.display_name,
exp_delta=formatLang(self.env, expense_delta, currency_obj=rec.currency_id),
dep_acc=rec.account_depreciation_id.display_name,
dep_delta=formatLang(self.env, accumulated_delta, currency_obj=rec.currency_id),
move_count=len(unreversed_posted),
word=_('entries') if len(unreversed_posted) > 1 else _('entry'),
)
+ Markup('<br>')
+ entry_descriptions
)
rec._message_log(body=summary)
else:
rec._message_log(body=_('Asset Cancelled'))
rec.depreciation_move_ids.filtered(
lambda mv: mv.state == 'draft'
).with_context(force_delete=True).unlink()
rec.asset_paused_days = 0
rec.write({'state': 'cancelled'})
def set_to_draft(self):
"""Reset the asset to draft state."""
self.write({'state': 'draft'})
def set_to_running(self):
"""Re-open a closed or cancelled asset, resetting net gain."""
if self.depreciation_move_ids:
final_entry = max(self.depreciation_move_ids, key=lambda mv: (mv.date, mv.id))
if final_entry.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):
"""
Transition a paused asset back to running state via the modification wizard,
which handles prorating the current period.
"""
self.ensure_one()
return self.with_context(resume_after_pause=True).action_asset_modify()
def pause(self, pause_date, message=None):
"""
Suspend depreciation: generate a partial-period entry up to the pause
date, then set the asset to 'paused' state.
"""
self.ensure_one()
self._create_move_before_date(pause_date)
self.write({'state': 'paused'})
self.message_post(body=_("Asset paused. %s", message or ""))
def open_asset(self, view_mode):
"""Return a window action to display the current asset(s)."""
if len(self) == 1:
view_mode = ['form']
view_list = [v for v in [(False, 'list'), (False, 'form')] if v[1] in view_mode]
ctx = dict(self.env.context)
ctx.pop('default_move_type', None)
return {
'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': view_list,
'domain': [('id', 'in', self.ids)],
'context': ctx,
}
# =========================================================================
# INTERNAL HELPERS
# =========================================================================
def _insert_depreciation_line(self, amount, begin_date, entry_date, day_count):
"""Create and return a single depreciation journal entry."""
self.ensure_one()
MoveModel = self.env['account.move']
return MoveModel.create(MoveModel._prepare_move_for_asset_depreciation({
'amount': amount,
'asset_id': self,
'depreciation_beginning_date': begin_date,
'date': entry_date,
'asset_number_days': day_count,
}))
def _post_non_deductible_tax_value(self):
"""Log a chatter message explaining non-deductible tax added to original value."""
if self.non_deductible_tax_value:
curr = self.env.company.currency_id
self.message_post(body=_(
'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=curr),
name=self.name,
purchase_value=formatLang(self.env, self.related_purchase_value, currency_obj=curr),
))
def _create_move_before_date(self, cutoff_date):
"""
Cancel all entries after cutoff_date, then create a partial depreciation
entry covering the period up to (and including) the cutoff date.
"""
posted_before = self.depreciation_move_ids.filtered(
lambda mv: (
mv.date <= cutoff_date
and not mv.reversal_move_ids
and not mv.reversed_entry_id
and mv.state == 'posted'
)
).sorted('date')
prior_dates = posted_before.mapped('date')
fy_start = (
self.company_id.compute_fiscalyear_dates(cutoff_date).get('date_from')
if self.method != 'linear'
else False
)
fy_lead_entry = self.env['account.move']
if prior_dates:
latest_posted_date = max(prior_dates)
# Find the beginning date of the next depreciation period
upcoming_starts = self.depreciation_move_ids.filtered(
lambda mv: mv.date > latest_posted_date and (
(not mv.reversal_move_ids and not mv.reversed_entry_id and mv.state == 'posted')
or mv.state == 'draft'
)
).mapped('asset_depreciation_beginning_date')
period_begin = min(upcoming_starts) if upcoming_starts else self.paused_prorata_date
if self.method != 'linear':
fy_candidates = self.depreciation_move_ids.filtered(
lambda mv: mv.asset_depreciation_beginning_date >= fy_start and (
(not mv.reversal_move_ids and not mv.reversed_entry_id and mv.state == 'posted')
or mv.state == 'draft'
)
).sorted(lambda mv: (mv.asset_depreciation_beginning_date, mv.id))
fy_lead_entry = next(iter(fy_candidates), fy_lead_entry)
else:
period_begin = self.paused_prorata_date
declining_start_val = (
fy_lead_entry.asset_remaining_value + fy_lead_entry.depreciation_value
)
self._cancel_future_moves(cutoff_date)
import_offset = self.already_depreciated_amount_import if not prior_dates else 0
residual_for_calc = (
self.value_residual + self.already_depreciated_amount_import
if not prior_dates
else self.value_residual
)
declining_start_val = declining_start_val or residual_for_calc
terminus = self._get_last_day_asset()
life_remaining = self._compute_day_span(period_begin, terminus)
day_count, depr_amount = self._compute_board_amount(
self.value_residual, period_begin, cutoff_date,
False, life_remaining, declining_start_val,
fy_start, life_remaining, residual_for_calc, period_begin,
)
if abs(import_offset) <= abs(depr_amount):
depr_amount -= import_offset
if not float_is_zero(depr_amount, precision_rounding=self.currency_id.rounding):
new_entry = self._insert_depreciation_line(
depr_amount, period_begin, cutoff_date, day_count
)
new_entry._post()
def _cancel_future_moves(self, cutoff_date):
"""
Remove or reverse depreciation entries dated after cutoff_date.
Draft entries are deleted; posted entries are reversed.
"""
for rec in self:
stale_entries = rec.depreciation_move_ids.filtered(
lambda mv: mv.state == 'draft' or (
not mv.reversal_move_ids
and not mv.reversed_entry_id
and mv.state == 'posted'
and mv.date > cutoff_date
)
)
stale_entries._unlink_or_reverse()
def _get_disposal_moves(self, invoice_lines_list, disposal_date):
"""
Generate disposal/sale journal entries for each asset, balancing
the original value, accumulated depreciation, and gain/loss accounts.
"""
def _build_line(label, asset_rec, amt, account):
return (0, 0, {
'name': label,
'account_id': account.id,
'balance': -amt,
'analytic_distribution': analytics,
'currency_id': asset_rec.currency_id.id,
'amount_currency': -asset_rec.company_id.currency_id._convert(
from_amount=amt,
to_currency=asset_rec.currency_id,
company=asset_rec.company_id,
date=disposal_date,
),
})
created_ids = []
assert len(self) == len(invoice_lines_list)
for rec, inv_lines in zip(self, invoice_lines_list):
rec._create_move_before_date(disposal_date)
analytics = rec.analytic_distribution
# Tally invoice line amounts per account
invoice_by_account = {}
inv_total = 0
orig_val = rec.original_value
orig_account = (
rec.original_move_line_ids.account_id
if len(rec.original_move_line_ids.account_id) == 1
else rec.account_asset_id
)
entries_before = rec.depreciation_move_ids.filtered(lambda mv: mv.date <= disposal_date)
cumulative_depr = rec.currency_id.round(copysign(
sum(entries_before.mapped('depreciation_value')) + rec.already_depreciated_amount_import,
-orig_val,
))
depr_account = rec.account_depreciation_id
for inv_line in inv_lines:
signed_bal = copysign(inv_line.balance, -orig_val)
invoice_by_account[inv_line.account_id] = (
invoice_by_account.get(inv_line.account_id, 0) + signed_bal
)
inv_total += signed_bal
acct_amount_pairs = list(invoice_by_account.items())
gap = -orig_val - cumulative_depr - inv_total
gap_account = (
rec.company_id.gain_account_id if gap > 0
else rec.company_id.loss_account_id
)
all_line_data = (
[(orig_val, orig_account), (cumulative_depr, depr_account)]
+ [(amt, acct) for acct, amt in acct_amount_pairs]
+ [(gap, gap_account)]
)
ref_label = (
_("%(asset)s: Sale", asset=rec.name)
if inv_lines
else _("%(asset)s: Disposal", asset=rec.name)
)
move_vals = {
'asset_id': rec.id,
'ref': ref_label,
'asset_depreciation_beginning_date': disposal_date,
'date': disposal_date,
'journal_id': rec.journal_id.id,
'move_type': 'entry',
'asset_move_type': 'sale' if inv_lines else 'disposal',
'line_ids': [
_build_line(ref_label, rec, amt, acct)
for amt, acct in all_line_data if acct
],
}
rec.write({'depreciation_move_ids': [(0, 0, move_vals)]})
created_ids += self.env['account.move'].search([
('asset_id', '=', rec.id), ('state', '=', 'draft')
]).ids
return created_ids
def _select_dominant_amount(self, remaining_value, declining_amount, straight_line_amount):
"""Pick the larger depreciation amount (by absolute value) respecting sign."""
if self.currency_id.compare_amounts(remaining_value, 0) > 0:
return max(declining_amount, straight_line_amount)
return min(declining_amount, straight_line_amount)
def _clamp_final_period(self, remaining_value, computed_amount, elapsed_days):
"""
In the final period (or when computed amount overshoots), clamp
the depreciation to the remaining value.
"""
if abs(remaining_value) < abs(computed_amount) or elapsed_days >= self.asset_lifetime_days:
return remaining_value
return computed_amount
def _get_own_book_value(self, date=None):
"""Book value of this asset alone (without children), optionally at a given date."""
self.ensure_one()
residual = self._get_residual_value_at_date(date) if date else self.value_residual
return residual + self.salvage_value
def _get_residual_value_at_date(self, target_date):
"""
Interpolate the asset's residual value at any arbitrary date by
finding the surrounding depreciation entries and prorating.
"""
relevant_entries = self.depreciation_move_ids.filtered(
lambda mv: (
mv.asset_depreciation_beginning_date < target_date
and not mv.reversed_entry_id
)
).sorted('asset_depreciation_beginning_date', reverse=True)
if not relevant_entries:
return 0
if len(relevant_entries) > 1:
prior_residual = relevant_entries[1].asset_remaining_value
else:
prior_residual = (
self.original_value - self.salvage_value - self.already_depreciated_amount_import
)
current_entry = relevant_entries[0]
entry_start = current_entry.asset_depreciation_beginning_date
entry_end = self._get_end_period_date(target_date)
proportion = (
self._compute_day_span(entry_start, target_date)
/ self._compute_day_span(entry_start, entry_end)
)
value_consumed = (prior_residual - current_entry.asset_remaining_value) * proportion
interpolated = self.currency_id.round(prior_residual - value_consumed)
if self.currency_id.compare_amounts(self.original_value, 0) > 0:
return max(interpolated, 0)
return min(interpolated, 0)
class FusionAssetGroup(models.Model):
"""Logical grouping of assets for organizational and reporting purposes."""
_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 assets in each group using a single grouped query."""
group_counts = {
grp.id: total
for grp, total in self.env['account.asset']._read_group(
domain=[('asset_group_id', 'in', self.ids)],
groupby=['asset_group_id'],
aggregates=['__count'],
)
}
for grp in self:
grp.count_linked_assets = group_counts.get(grp.id, 0)
def action_open_linked_assets(self):
"""Open a list/form view of assets belonging to this group."""
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 FusionAssetReportHandler(models.AbstractModel):
"""
Custom report handler for the depreciation schedule report.
Generates asset-level rows with acquisition, depreciation, and book value columns,
and supports grouping by account or asset group.
"""
_name = 'account.asset.report.handler'
_inherit = 'account.report.custom.handler'
_description = 'Assets Report Custom Handler'
def _get_custom_display_config(self):
"""Configure the report's CSS class and filter template."""
return {
'client_css_custom_class': 'depreciation_schedule',
'templates': {
'AccountReportFilters': 'fusion_accounting.DepreciationScheduleFilters',
},
}
def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
"""
Main entry point: generate all report lines, optionally grouped,
with a grand total row at the bottom.
"""
detail_lines, col_group_totals = self._build_ungrouped_lines(report, options)
# Apply grouping if configured
grouping_mode = options['assets_grouping_field']
if grouping_mode != 'none':
detail_lines = self._apply_grouping(report, detail_lines, options)
else:
detail_lines = report._regroup_lines_by_name_prefix(
options, detail_lines,
'_report_expand_unfoldable_line_assets_report_prefix_group', 0,
)
# Build grand total row
total_cols = []
for col_def in options['columns']:
col_key = col_def['column_group_key']
expr = col_def['expression_label']
raw_val = col_group_totals[col_key].get(expr)
display_val = raw_val if col_def.get('figure_type') == 'monetary' else ''
total_cols.append(report._build_column_dict(display_val, col_def, options=options))
if detail_lines:
detail_lines.append({
'id': report._get_generic_line_id(None, None, markup='total'),
'level': 1,
'name': _('Total'),
'columns': total_cols,
'unfoldable': False,
'unfolded': False,
})
return [(0, line) for line in detail_lines]
def _build_ungrouped_lines(self, report, options, name_prefix=None, parent_line_id=None, restrict_account_id=None):
"""
Query the database and construct one report line per asset.
Returns (lines, totals_by_column_group).
"""
all_asset_ids = set()
combined_data = {}
for cg_key, cg_options in report._split_options_per_column_group(options).items():
query_rows = self._fetch_report_rows(
cg_options, name_prefix=name_prefix, restrict_account_id=restrict_account_id,
)
for acct_id, asset_id, group_id, expr_vals in query_rows:
key = (acct_id, asset_id, group_id)
all_asset_ids.add(asset_id)
combined_data.setdefault(key, {})[cg_key] = expr_vals
column_labels = [
'assets_date_from', 'assets_plus', 'assets_minus', 'assets_date_to',
'depre_date_from', 'depre_plus', 'depre_minus', 'depre_date_to', 'balance',
]
col_group_totals = defaultdict(lambda: dict.fromkeys(column_labels, 0.0))
asset_cache = {a.id: a for a in self.env['account.asset'].browse(all_asset_ids)}
company_curr = self.env.company.currency_id
expr_model = self.env['account.report.expression']
output_lines = []
for (acct_id, asset_id, group_id), cg_data in combined_data.items():
row_columns = []
for col_def in options['columns']:
cg_key = col_def['column_group_key']
expr = col_def['expression_label']
if cg_key not in cg_data or expr not in cg_data[cg_key]:
row_columns.append(report._build_column_dict(None, None))
continue
val = cg_data[cg_key][expr]
col_meta = None if val is None else col_def
row_columns.append(report._build_column_dict(
val, col_meta, options=options,
column_expression=expr_model, currency=company_curr,
))
if col_def['figure_type'] == 'monetary':
col_group_totals[cg_key][expr] += val
asset_name = asset_cache[asset_id].name
line_dict = {
'id': report._get_generic_line_id('account.asset', asset_id, parent_line_id=parent_line_id),
'level': 2,
'name': asset_name,
'columns': row_columns,
'unfoldable': False,
'unfolded': False,
'caret_options': 'account_asset_line',
'assets_account_id': acct_id,
'assets_asset_group_id': group_id,
}
if parent_line_id:
line_dict['parent_id'] = parent_line_id
if len(asset_name) >= REPORT_NAME_TRUNCATION:
line_dict['title_hover'] = asset_name
output_lines.append(line_dict)
return output_lines, col_group_totals
def _caret_options_initializer(self):
"""Define the caret menu option for opening asset records."""
return {
'account_asset_line': [
{'name': _("Open Asset"), 'action': 'caret_option_open_record_form'},
],
}
def _custom_options_initializer(self, report, options, previous_options):
"""Configure dynamic column headers and subheaders for the depreciation schedule."""
super()._custom_options_initializer(report, options, previous_options=previous_options)
cg_map = report._split_options_per_column_group(options)
for col in options['columns']:
cg_opts = cg_map[col['column_group_key']]
if col['expression_label'] == 'balance':
col['name'] = ''
elif col['expression_label'] in ('assets_date_from', 'depre_date_from'):
col['name'] = format_date(self.env, cg_opts['date']['date_from'])
elif col['expression_label'] in ('assets_date_to', 'depre_date_to'):
col['name'] = format_date(self.env, cg_opts['date']['date_to'])
options['custom_columns_subheaders'] = [
{"name": _("Characteristics"), "colspan": 4},
{"name": _("Assets"), "colspan": 4},
{"name": _("Depreciation"), "colspan": 4},
{"name": _("Book Value"), "colspan": 1},
]
options['assets_grouping_field'] = previous_options.get('assets_grouping_field') or 'account_id'
has_groups = self.env['account.group'].search_count(
[('company_id', '=', self.env.company.id)], limit=1,
)
hierarchy_pref = previous_options.get('hierarchy', True)
options['hierarchy'] = has_groups and hierarchy_pref or False
def _fetch_report_rows(self, options, name_prefix=None, restrict_account_id=None):
"""
Execute the SQL query for the depreciation schedule and transform
each raw row into (account_id, asset_id, group_id, {expr_label: value}).
"""
raw_rows = self._execute_schedule_query(
options, name_prefix=name_prefix, restrict_account_id=restrict_account_id,
)
result = []
# Separate parent assets from children (gross increases)
parent_rows = []
children_by_parent = defaultdict(list)
for row in raw_rows:
if row['parent_id']:
children_by_parent[row['parent_id']].append(row)
else:
parent_rows.append(row)
for row in parent_rows:
child_rows = children_by_parent.get(row['asset_id'], [])
schedule_values = self._get_parent_asset_values(options, row, child_rows)
# Method display label
method_label = {
'linear': _("Linear"),
'degressive': _("Declining"),
}.get(row['asset_method'], _("Dec. then Straight"))
columns = {
'acquisition_date': (
format_date(self.env, row['asset_acquisition_date'])
if row['asset_acquisition_date'] else ""
),
'first_depreciation': (
format_date(self.env, row['asset_date'])
if row['asset_date'] else ""
),
'method': method_label,
**schedule_values,
}
result.append((row['account_id'], row['asset_id'], row['asset_group_id'], columns))
return result
def _get_parent_asset_values(self, options, asset_row, child_rows):
"""
Compute opening/closing balances and depreciation movements for an asset
and its gross-increase children within the report period.
"""
# Depreciation rate display
depr_method = asset_row['asset_method']
periods = asset_row['asset_method_number']
if depr_method == 'linear' and periods:
total_months = int(periods) * int(asset_row['asset_method_period'])
yr = total_months // 12
mo = total_months % 12
rate_parts = []
if yr:
rate_parts.append(_("%(years)s y", years=yr))
if mo:
rate_parts.append(_("%(months)s m", months=mo))
rate_str = " ".join(rate_parts)
elif depr_method == 'linear':
rate_str = '0.00 %'
else:
rate_str = '{:.2f} %'.format(float(asset_row['asset_method_progress_factor']) * 100)
period_from = fields.Date.to_date(options['date']['date_from'])
period_to = fields.Date.to_date(options['date']['date_to'])
# Determine if the asset was acquired before the report period
acq_or_first = asset_row['asset_acquisition_date'] or asset_row['asset_date']
existed_before_period = acq_or_first < period_from
# Base depreciation figures
depr_opening = asset_row['depreciated_before']
depr_additions = asset_row['depreciated_during']
depr_removals = 0.0
disposal_val = 0.0
if asset_row['asset_disposal_date'] and asset_row['asset_disposal_date'] <= period_to:
disposal_val = asset_row['asset_disposal_value']
asset_opening = asset_row['asset_original_value'] if existed_before_period else 0.0
asset_additions = 0.0 if existed_before_period else asset_row['asset_original_value']
asset_removals = 0.0
salvage = asset_row.get('asset_salvage_value', 0.0)
# Accumulate child (gross increase) values
for child in child_rows:
depr_opening += child['depreciated_before']
depr_additions += child['depreciated_during']
child_acq = child['asset_acquisition_date'] or child['asset_date']
child_existed = child_acq < period_from
asset_opening += child['asset_original_value'] if child_existed else 0.0
asset_additions += 0.0 if child_existed else child['asset_original_value']
# Closing figures
asset_closing = asset_opening + asset_additions - asset_removals
depr_closing = depr_opening + depr_additions - depr_removals
asset_curr = self.env['res.currency'].browse(asset_row['asset_currency_id'])
# Handle fully depreciated & closed assets
if (
asset_row['asset_state'] == 'close'
and asset_row['asset_disposal_date']
and asset_row['asset_disposal_date'] <= period_to
and asset_curr.compare_amounts(depr_closing, asset_closing - salvage) == 0
):
depr_additions -= disposal_val
depr_removals += depr_closing - disposal_val
depr_closing = 0.0
asset_removals += asset_closing
asset_closing = 0.0
# Invert signs for credit-note (negative) assets
if asset_curr.compare_amounts(asset_row['asset_original_value'], 0) < 0:
asset_additions, asset_removals = -asset_removals, -asset_additions
depr_additions, depr_removals = -depr_removals, -depr_additions
return {
'duration_rate': rate_str,
'asset_disposal_value': disposal_val,
'assets_date_from': asset_opening,
'assets_plus': asset_additions,
'assets_minus': asset_removals,
'assets_date_to': asset_closing,
'depre_date_from': depr_opening,
'depre_plus': depr_additions,
'depre_minus': depr_removals,
'depre_date_to': depr_closing,
'balance': asset_closing - depr_closing,
}
def _apply_grouping(self, report, lines, options):
"""
Organize flat asset lines into collapsible groups based on the
configured grouping field (account or asset group).
"""
if not lines:
return lines
groups = {}
grouping_model = (
'account.account'
if options['assets_grouping_field'] == 'account_id'
else 'account.asset.group'
)
for line in lines:
group_key = (
line.get('assets_account_id')
if options['assets_grouping_field'] == 'account_id'
else line.get('assets_asset_group_id')
)
_model, record_id = report._get_model_info_from_id(line['id'])
line['id'] = report._build_line_id([
(None, grouping_model, group_key),
(None, 'account.asset', record_id),
])
is_unfolded = any(
report._get_model_info_from_id(uid) == (grouping_model, group_key)
for uid in options.get('unfolded_lines', [])
)
groups.setdefault(group_key, {
'id': report._build_line_id([(None, grouping_model, group_key)]),
'columns': [],
'unfoldable': True,
'unfolded': is_unfolded or options.get('unfold_all'),
'level': 1,
'group_lines': [],
})['group_lines'].append(line)
# Build the final list with group headers and their children
result = []
monetary_col_indices = [
idx for idx, col in enumerate(options['columns'])
if col['figure_type'] == 'monetary'
]
group_records = self.env[grouping_model].browse(groups.keys())
for grp_rec in group_records:
header = groups[grp_rec.id]
if options['assets_grouping_field'] == 'account_id':
header['name'] = f"{grp_rec.code} {grp_rec.name}"
else:
header['name'] = grp_rec.name or _('(No %s)', grp_rec._description)
result.append(header)
running_totals = {ci: 0 for ci in monetary_col_indices}
nested_lines = report._regroup_lines_by_name_prefix(
options,
header.pop('group_lines'),
'_report_expand_unfoldable_line_assets_report_prefix_group',
header['level'],
parent_line_dict_id=header['id'],
)
for child_line in nested_lines:
for ci in monetary_col_indices:
running_totals[ci] += child_line['columns'][ci].get('no_format', 0)
child_line['parent_id'] = header['id']
result.append(child_line)
# Populate the group header's column totals
for ci in range(len(options['columns'])):
header['columns'].append(report._build_column_dict(
running_totals.get(ci, ''),
options['columns'][ci],
options=options,
))
return result
def _execute_schedule_query(self, options, name_prefix=None, restrict_account_id=None):
"""
Build and execute the SQL query that retrieves asset data for the
depreciation schedule report.
"""
self.env['account.move.line'].check_access('read')
self.env['account.asset'].check_access('read')
qry = Query(self.env, alias='asset', table=SQL.identifier('account_asset'))
acct_alias = qry.join(
lhs_alias='asset', lhs_column='account_asset_id',
rhs_table='account_account', rhs_column='id',
link='account_asset_id',
)
move_state_filter = "!= 'cancel'" if options.get('all_entries') else "= 'posted'"
qry.add_join('LEFT JOIN', alias='move', table='account_move', condition=SQL(
f"move.asset_id = asset.id AND move.state {move_state_filter}"
))
acct_code_sql = self.env['account.account']._field_to_sql(acct_alias, 'code', qry)
acct_name_sql = self.env['account.account']._field_to_sql(acct_alias, 'name')
acct_id_sql = SQL.identifier(acct_alias, 'id')
if name_prefix:
qry.add_where(SQL("asset.name ILIKE %s", f"{name_prefix}%"))
if restrict_account_id:
qry.add_where(SQL("%s = %s", acct_id_sql, restrict_account_id))
# Analytic filtering
analytic_ids = []
opt_analytics = options.get('analytic_accounts', [])
opt_analytics_list = options.get('analytic_accounts_list', [])
if opt_analytics and not any(x in opt_analytics_list for x in opt_analytics):
analytic_ids.append([str(aid) for aid in opt_analytics])
if opt_analytics_list:
analytic_ids.append([str(aid) for aid in opt_analytics_list])
if analytic_ids:
qry.add_where(SQL(
'%s && %s',
analytic_ids,
self.env['account.asset']._query_analytic_accounts('asset'),
))
# Journal filtering
active_journals = tuple(
j['id'] for j in options.get('journals', [])
if j['model'] == 'account.journal' and j['selected']
)
if active_journals:
qry.add_where(SQL("asset.journal_id in %s", active_journals))
stmt = 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,
%(acct_code)s AS account_code,
%(acct_name)s AS account_name,
%(acct_id)s AS account_id,
COALESCE(SUM(move.depreciation_value) FILTER (WHERE move.date < %(dt_from)s), 0)
+ COALESCE(asset.already_depreciated_amount_import, 0) AS depreciated_before,
COALESCE(SUM(move.depreciation_value) FILTER (WHERE move.date BETWEEN %(dt_from)s AND %(dt_to)s), 0)
AS depreciated_during,
COALESCE(SUM(move.depreciation_value) FILTER (
WHERE move.date BETWEEN %(dt_from)s AND %(dt_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 <= %(dt_to)s OR move.date <= %(dt_to)s)
AND (asset.disposal_date >= %(dt_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
""",
acct_code=acct_code_sql,
acct_name=acct_name_sql,
acct_id=acct_id_sql,
dt_from=options['date']['date_from'],
dt_to=options['date']['date_to'],
from_clause=qry.from_clause,
where_clause=qry.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.env.cr.execute(stmt)
return self.env.cr.dictfetchall()
def _report_expand_unfoldable_line_assets_report_prefix_group(
self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None,
):
"""Handle expansion of prefix-grouped unfoldable lines in the report."""
prefix = self.env['account.report']._get_prefix_groups_matched_prefix_from_line_id(line_dict_id)
rpt = self.env['account.report'].browse(options['report_id'])
expanded_lines, _totals = self._build_ungrouped_lines(
rpt, options,
name_prefix=prefix,
parent_line_id=line_dict_id,
restrict_account_id=self.env['account.report']._get_res_id_from_line_id(
line_dict_id, 'account.account'
),
)
expanded_lines = rpt._regroup_lines_by_name_prefix(
options, expanded_lines,
'_report_expand_unfoldable_line_assets_report_prefix_group',
len(prefix),
matched_prefix=prefix,
parent_line_dict_id=line_dict_id,
)
return {
'lines': expanded_lines,
'offset_increment': len(expanded_lines),
'has_more': False,
}
class AssetsReport(models.Model):
"""Extend account.report to register asset-specific caret options."""
_inherit = 'account.report'
def _get_caret_option_view_map(self):
view_map = super()._get_caret_option_view_map()
view_map['account.asset.line'] = 'fusion_accountingview_account_asset_expense_form'
return view_map