2192 lines
94 KiB
Python
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
|