You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

425 lines
22 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _, _lt, Command
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_compare
from odoo.tools.misc import formatLang
from dateutil.relativedelta import relativedelta
from collections import defaultdict, namedtuple
class AccountMove(models.Model):
_inherit = 'account.move'
asset_id = fields.Many2one('account.asset', string='Asset', index=True, ondelete='cascade', copy=False, domain="[('company_id', '=', company_id)]")
asset_asset_type = fields.Selection(related='asset_id.asset_type')
asset_remaining_value = fields.Monetary(string='Depreciable Value', compute='_compute_depreciation_cumulative_value')
asset_depreciated_value = fields.Monetary(string='Cumulative Depreciation', compute='_compute_depreciation_cumulative_value')
# true when this move is the result of the changing of value of an asset
asset_value_change = fields.Boolean()
# how many days of depreciation this entry corresponds to
asset_number_days = fields.Integer(string="Number of days", copy=False)
asset_depreciation_beginning_date = fields.Date(string="Date of the beginning of the depreciation", copy=False) # technical field stating when the depreciation associated with this entry has begun
depreciation_value = fields.Monetary(
string="Depreciation",
compute="_compute_depreciation_value", inverse="_inverse_depreciation_value", store=True,
)
asset_ids = fields.One2many('account.asset', string='Assets', compute="_compute_asset_ids")
linked_asset_type = fields.Char(compute="_compute_asset_ids") # just a button label. That's to avoid a plethora of different buttons defined in xml
asset_id_display_name = fields.Char(compute="_compute_asset_ids") # just a button label. That's to avoid a plethora of different buttons defined in xml
number_asset_ids = fields.Integer(compute="_compute_asset_ids")
draft_asset_ids = fields.Boolean(compute="_compute_asset_ids")
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends('asset_id', 'depreciation_value', 'asset_id.total_depreciable_value', 'asset_id.already_depreciated_amount_import')
def _compute_depreciation_cumulative_value(self):
for asset in self.asset_id:
depreciated = 0
remaining = asset.total_depreciable_value - asset.already_depreciated_amount_import
for move in asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv._origin.id)):
remaining -= move.depreciation_value
depreciated += move.depreciation_value
move.asset_remaining_value = remaining
move.asset_depreciated_value = depreciated
@api.depends('line_ids.balance')
def _compute_depreciation_value(self):
for move in self:
asset = move.asset_id or move.reversed_entry_id.asset_id # reversed moves are created before being assigned to the asset
if asset:
account = asset.account_depreciation_expense_id if asset.asset_type != 'sale' else asset.account_depreciation_id
asset_depreciation = sum(
move.line_ids.filtered(lambda l: l.account_id == account).mapped('balance')
)
if asset.asset_type == 'sale':
asset_depreciation *= -1
# Special case of closing entry - only disposed assets of type 'purchase' should match this condition
if any(
(line.account_id, -line.balance) == (asset.account_asset_id, asset.original_value)
for line in move.line_ids
):
account = asset.account_depreciation_id
asset_depreciation = (
asset.original_value
- asset.salvage_value
- sum(
move.line_ids.filtered(lambda l: l.account_id == account).mapped(
'debit' if asset.original_value > 0 else 'credit'
)
) * (-1 if asset.original_value < 0 else 1)
)
else:
asset_depreciation = 0
move.depreciation_value = asset_depreciation
# -------------------------------------------------------------------------
# INVERSE METHODS
# -------------------------------------------------------------------------
def _inverse_depreciation_value(self):
for move in self:
asset = move.asset_id
amount = abs(move.depreciation_value)
account = asset.account_depreciation_expense_id if asset.asset_type != 'sale' else asset.account_depreciation_id
move.write({'line_ids': [
Command.update(line.id, {
'balance': amount if line.account_id == account else -amount,
})
for line in move.line_ids
]})
# -------------------------------------------------------------------------
# CONSTRAINT METHODS
# -------------------------------------------------------------------------
@api.constrains('state', 'asset_id')
def _constrains_check_asset_state(self):
for move in self.filtered(lambda mv: mv.asset_id):
asset_id = move.asset_id
if asset_id.state == 'draft' and move.state == 'posted':
raise ValidationError(_("You can't post an entry related to a draft asset. Please post the asset before."))
def _post(self, soft=True):
# OVERRIDE
posted = super()._post(soft)
# log the post of a depreciation
posted._log_depreciation_asset()
# look for any asset to create, in case we just posted a bill on an account
# configured to automatically create assets
posted._auto_create_asset()
# check if we are reversing a move and delete assets of original move if it's the case
posted._delete_reversed_entry_assets()
# close deferred expense/revenue if all their depreciation moves are posted
posted._close_assets()
return posted
def _reverse_moves(self, default_values_list=None, cancel=False):
if default_values_list is None:
default_values_list = [{} for _i in self]
for move, default_values in zip(self, default_values_list):
# Report the value of this move to the next draft move or create a new one
if move.asset_id:
# Recompute the status of the asset for all depreciations posted after the reversed entry
first_draft = min(move.asset_id.depreciation_move_ids.filtered(lambda m: m.state == 'draft'), key=lambda m: m.date, default=None)
if first_draft:
# If there is a draft, simply move/add the depreciation amount here
first_draft.depreciation_value += move.depreciation_value
else:
# If there was no draft move left, create one
last_date = max(move.asset_id.depreciation_move_ids.mapped('date'))
method_period = move.asset_id.method_period
self.create(self._prepare_move_for_asset_depreciation({
'asset_id': move.asset_id,
'amount': move.depreciation_value,
'depreciation_beginning_date': last_date + (relativedelta(months=1) if method_period == "1" else relativedelta(years=1)),
'date': last_date + (relativedelta(months=1) if method_period == "1" else relativedelta(years=1)),
'asset_number_days': 0
}))
msg = _('Depreciation entry %s reversed (%s)') % (move.name, formatLang(self.env, move.depreciation_value, currency_obj=move.company_id.currency_id))
move.asset_id.message_post(body=msg)
default_values['asset_id'] = move.asset_id.id
default_values['asset_number_days'] = -move.asset_number_days
return super(AccountMove, self)._reverse_moves(default_values_list, cancel)
def button_cancel(self):
# OVERRIDE
res = super(AccountMove, self).button_cancel()
self.env['account.asset'].sudo().search([('original_move_line_ids.move_id', 'in', self.ids)]).write({'active': False})
return res
def button_draft(self):
for move in self:
if any(asset_id.state != 'draft' for asset_id in move.asset_ids):
raise UserError(_('You cannot reset to draft an entry related to a posted asset'))
# Remove any draft asset that could be linked to the account move being reset to draft
move.asset_ids.filtered(lambda x: x.state == 'draft').unlink()
return super(AccountMove, self).button_draft()
def _log_depreciation_asset(self):
for move in self.filtered(lambda m: m.asset_id):
asset = move.asset_id
msg = _('Depreciation entry %s posted (%s)') % (move.name, formatLang(self.env, move.depreciation_value, currency_obj=move.company_id.currency_id))
asset.message_post(body=msg)
def _auto_create_asset(self):
create_list = []
invoice_list = []
auto_validate = []
for move in self:
if not move.is_invoice():
continue
for move_line in move.line_ids:
if (
move_line.account_id
and (move_line.account_id.can_create_asset)
and move_line.account_id.create_asset != "no"
and not (move_line.currency_id or move.currency_id).is_zero(move_line.price_total)
and not move_line.asset_ids
and not move_line.tax_line_id
and move_line.price_total > 0
and not (move.move_type in ('out_invoice', 'out_refund') and move_line.account_id.internal_group == 'asset')
):
if not move_line.name:
raise UserError(_('Journal Items of {account} should have a label in order to generate an asset').format(account=move_line.account_id.display_name))
if move_line.account_id.multiple_assets_per_line:
# decimal quantities are not supported, quantities are rounded to the lower int
units_quantity = max(1, int(move_line.quantity))
else:
units_quantity = 1
vals = {
'name': move_line.name,
'company_id': move_line.company_id.id,
'currency_id': move_line.company_currency_id.id,
'analytic_distribution': move_line.analytic_distribution,
'original_move_line_ids': [(6, False, move_line.ids)],
'state': 'draft',
'acquisition_date': move.invoice_date,
}
model_id = move_line.account_id.asset_model
if model_id:
vals.update({
'model_id': model_id.id,
})
auto_validate.extend([move_line.account_id.create_asset == 'validate'] * units_quantity)
invoice_list.extend([move] * units_quantity)
for i in range(1, units_quantity + 1):
if units_quantity > 1:
vals['name'] = move_line.name + _(" (%s of %s)", i, units_quantity)
create_list.extend([vals.copy()])
assets = self.env['account.asset'].create(create_list)
for asset, vals, invoice, validate in zip(assets, create_list, invoice_list, auto_validate):
if 'model_id' in vals:
asset._onchange_model_id()
if validate:
asset.validate()
if invoice:
asset_name = {
'purchase': _lt('Asset'),
'sale': _lt('Deferred revenue'),
'expense': _lt('Deferred expense'),
}[asset.asset_type]
asset.message_post(body=_('%s created from invoice: %s', asset_name, invoice._get_html_link()))
asset._post_non_deductible_tax_value()
return assets
@api.model
def _prepare_move_for_asset_depreciation(self, vals):
missing_fields = set(['asset_id', 'amount', 'depreciation_beginning_date', 'date', 'asset_number_days']) - set(vals)
if missing_fields:
raise UserError(_('Some fields are missing {}').format(', '.join(missing_fields)))
asset = vals['asset_id']
analytic_distribution = asset.analytic_distribution
depreciation_date = vals.get('date', fields.Date.context_today(self))
company_currency = asset.company_id.currency_id
current_currency = asset.currency_id
prec = company_currency.decimal_places
amount_currency = vals['amount']
amount = current_currency._convert(amount_currency, company_currency, asset.company_id, depreciation_date)
# Keep the partner on the original invoice if there is only one
partner = asset.original_move_line_ids.mapped('partner_id')
partner = partner[:1] if len(partner) <= 1 else self.env['res.partner']
move_line_1 = {
'name': asset.name,
'partner_id': partner.id,
'account_id': asset.account_depreciation_id.id,
'debit': 0.0 if float_compare(amount, 0.0, precision_digits=prec) > 0 else -amount,
'credit': amount if float_compare(amount, 0.0, precision_digits=prec) > 0 else 0.0,
'analytic_distribution': analytic_distribution if asset.asset_type == 'sale' else {},
'currency_id': current_currency.id,
'amount_currency': -amount_currency,
}
move_line_2 = {
'name': asset.name,
'partner_id': partner.id,
'account_id': asset.account_depreciation_expense_id.id,
'credit': 0.0 if float_compare(amount, 0.0, precision_digits=prec) > 0 else -amount,
'debit': amount if float_compare(amount, 0.0, precision_digits=prec) > 0 else 0.0,
'analytic_distribution': analytic_distribution if asset.asset_type in ('purchase', 'expense') else {},
'currency_id': current_currency.id,
'amount_currency': amount_currency,
}
move_vals = {
'partner_id': partner.id,
'date': depreciation_date,
'journal_id': asset.journal_id.id,
'line_ids': [(0, 0, move_line_1), (0, 0, move_line_2)],
'asset_id': asset.id,
'ref': _("%s: Depreciation", asset.name),
'asset_depreciation_beginning_date': vals['depreciation_beginning_date'],
'asset_number_days': vals['asset_number_days'],
'name': '/',
'asset_value_change': vals.get('asset_value_change', False),
'move_type': 'entry',
'currency_id': current_currency.id,
}
return move_vals
@api.depends('line_ids.asset_ids')
def _compute_asset_ids(self):
for record in self:
record.asset_ids = record.line_ids.asset_ids
record.number_asset_ids = len(record.asset_ids)
record.linked_asset_type = record.asset_ids[:1].asset_type
record.asset_id_display_name = {'sale': _('Revenue'), 'purchase': _('Asset'), 'expense': _('Expense')}.get(record.asset_id.asset_type)
record.draft_asset_ids = bool(record.asset_ids.filtered(lambda x: x.state == "draft"))
def open_asset_view(self):
return self.asset_id.open_asset(['form'])
def action_open_asset_ids(self):
return self.asset_ids.open_asset(['tree', 'form'])
def _delete_reversed_entry_assets(self):
ReverseKey = namedtuple('ReverseKey', ['product_id', 'price_unit', 'quantity'])
def build_key(line):
return ReverseKey(**{k: line[k] for k in ReverseKey._fields})
for move in self.filtered(lambda m: m.reversed_entry_id):
reversed_products = move.invoice_line_ids.mapped(build_key)
# handle single asset per line by checking match on product_id, price_unit and quantity
for line in move.reversed_entry_id.line_ids.filtered(lambda l: (
l.asset_ids
and not l.account_id.multiple_assets_per_line
and build_key(l) in reversed_products
)):
try:
index = reversed_products.index(build_key(line))
except ValueError:
continue
for asset in line.asset_ids:
if asset.state == 'draft' or all(state == 'draft' for state in asset.depreciation_move_ids.mapped('state')):
asset.state = 'draft'
asset.unlink()
del reversed_products[index]
# handle multiple assets per line by counting the remaining reversed quantities
rp_count = defaultdict(float)
for rp in reversed_products:
rp_count[(rp.product_id.id, rp.price_unit)] += rp.quantity
for line in move.reversed_entry_id.line_ids.filtered(lambda l: (
l.asset_ids
and l.account_id.multiple_assets_per_line
and rp_count.get((l.product_id.id, l.price_unit))
)):
for asset in line.asset_ids:
if (
rp_count[(line.product_id.id, line.price_unit)] > 0
and (asset.state == 'draft' or all(
state == 'draft'
for state in asset.depreciation_move_ids.mapped('state')
))
):
asset.state = 'draft'
asset.unlink()
rp_count[(line.product_id.id, line.price_unit)] -= 1
def _close_assets(self):
for asset in self.asset_id:
if asset.asset_type in ('expense', 'sale') and all(m.state == 'posted' for m in asset.depreciation_move_ids):
asset.write({'state': 'close'})
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
asset_ids = fields.Many2many('account.asset', 'asset_move_line_rel', 'line_id', 'asset_id', string='Related Assets', copy=False)
non_deductible_tax_value = fields.Monetary(compute='_compute_non_deductible_tax_value', currency_field='company_currency_id')
def _turn_as_asset(self, asset_type, view_name, view):
ctx = self.env.context.copy()
ctx.update({
'default_original_move_line_ids': [(6, False, self.env.context['active_ids'])],
'default_company_id': self.company_id.id,
'asset_type': asset_type,
'default_asset_type': asset_type,
})
if any(line.move_id.state == 'draft' for line in self):
raise UserError(_("All the lines should be posted"))
if any(account != self[0].account_id for account in self.mapped('account_id')):
raise UserError(_("All the lines should be from the same account"))
return {
"name": view_name,
"type": "ir.actions.act_window",
"res_model": "account.asset",
"views": [[view.id, "form"]],
"target": "current",
"context": ctx,
}
def turn_as_asset(self):
return self._turn_as_asset('purchase', _("Turn as an asset"), self.env.ref("account_asset.view_account_asset_form"))
def turn_as_deferred(self):
balance = sum(aml.debit - aml.credit for aml in self)
if balance > 0:
return self._turn_as_asset('expense', _("Turn as a deferred expense"), self.env.ref('account_asset.view_account_asset_expense_form'))
else:
return self._turn_as_asset('sale', _("Turn as a deferred revenue"), self.env.ref('account_asset.view_account_asset_revenue_form'))
@api.depends('tax_ids.invoice_repartition_line_ids')
def _compute_non_deductible_tax_value(self):
""" Handle the specific case of non deductible taxes,
such as "50% Non Déductible - Frais de voiture (Prix Excl.)" in Belgium.
"""
non_deductible_tax_ids = self.tax_ids.invoice_repartition_line_ids.filtered(
lambda line: line.repartition_type == 'tax' and not line.use_in_tax_closing
).tax_id
res = {}
if non_deductible_tax_ids:
domain = [('move_id', 'in', self.move_id.ids)]
tax_details_query, tax_details_params = self._get_query_tax_details_from_domain(domain)
self.flush_model()
self._cr.execute(f'''
SELECT
tdq.base_line_id,
SUM(tdq.tax_amount_currency)
FROM ({tax_details_query}) AS tdq
JOIN account_move_line aml ON aml.id = tdq.tax_line_id
JOIN account_tax_repartition_line trl ON trl.id = tdq.tax_repartition_line_id
WHERE tdq.base_line_id IN %s
AND trl.use_in_tax_closing IS FALSE
GROUP BY tdq.base_line_id
''', tax_details_params + [tuple(self.ids)])
res = {row['base_line_id']: row['sum'] for row in self._cr.dictfetchall()}
for record in self:
record.non_deductible_tax_value = res.get(record._origin.id, 0.0)