From 254a5edeb18b5eba48eebf0e5443b70cad749a9c Mon Sep 17 00:00:00 2001 From: git_admin Date: Fri, 1 May 2026 14:25:12 +0000 Subject: [PATCH] Tower: upload base_accounting_kit 19.0.2.3.1 (via marketplace) --- .../models/account_asset_asset.py | 623 ++++++++++++++++++ 1 file changed, 623 insertions(+) create mode 100644 addons/base_accounting_kit/models/account_asset_asset.py diff --git a/addons/base_accounting_kit/models/account_asset_asset.py b/addons/base_accounting_kit/models/account_asset_asset.py new file mode 100644 index 0000000..0562ec3 --- /dev/null +++ b/addons/base_accounting_kit/models/account_asset_asset.py @@ -0,0 +1,623 @@ +# -*- coding: utf-8 -*- +############################################################################# +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2025-TODAY Cybrosys Technologies() +# Author: Cybrosys Techno Solutions() +# +# You can modify it under the terms of the GNU LESSER +# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. +# +# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE +# (LGPL v3) along with this program. +# If not, see . +# +############################################################################# +import calendar +from datetime import date, datetime +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models, _ +from odoo.fields import Date +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF, float_is_zero +from odoo.exceptions import UserError, ValidationError + + +class AccountAssetAsset(models.Model): + """ + Model for managing assets with depreciation functionality + """ + _name = 'account.asset.asset' + _description = 'Asset/Revenue Recognition' + _inherit = ['mail.thread'] + + entry_count = fields.Integer(compute='_entry_count', + string='# Asset Entries') + name = fields.Char(string='Asset Name', required=True) + code = fields.Char(string='Reference', size=32) + value = fields.Float(string='Gross Value', required=True, + digits=0) + currency_id = fields.Many2one('res.currency', string='Currency', + required=True, + default=lambda self: self.env.company.currency_id.id) + company_id = fields.Many2one('res.company', string='Company', + required=True, + default=lambda self: self.env.company) + note = fields.Text() + category_id = fields.Many2one('account.asset.category', string='Asset Model', + required=False, change_default=True + ) + date = fields.Date(string='Date', required=True, + default=fields.Date.context_today) + state = fields.Selection( + [('draft', 'Draft'), ('open', 'Running'), ('close', 'Close'),('cancelled','Cancelled')], + 'Status', required=True, copy=False, default='draft', + help="When an asset is created, the status is 'Draft'.\n" + "If the asset is confirmed, the status goes in 'Running' and the depreciation lines can be posted in the accounting.\n" + "You can manually close an asset when the depreciation is over. If the last line of depreciation is posted, the asset automatically goes in that status.") + active = fields.Boolean(default=True) + partner_id = fields.Many2one('res.partner', string='Partner') + method = fields.Selection( + [('linear', 'Straight Line'), ('degressive', 'Declining')], + string='Computation Method', required=True,default='linear', + help="Choose the method to use to compute the amount of depreciation lines.\n * Linear: Calculated on basis of: Gross Value / Number of Depreciations\n" + " * Degressive: Calculated on basis of: Residual Value * Degressive Factor") + method_number = fields.Integer(string='Number of Depreciations', + default=5, + help="The number of depreciation's needed to depreciate your asset") + method_period = fields.Integer(string='Number of Months in a Period', + required=True, default=12, + help="The amount of time between two depreciation's, in months") + method_end = fields.Date(string='Ending Date') + method_progress_factor = fields.Float(string='Degressive Factor', + default=0.3,) + value_residual = fields.Float(compute='_amount_residual', + digits=0, string='Residual Value') + method_time = fields.Selection( + [('number', 'Number of Entries'), ('end', 'Ending Date')], + string='Time Method', required=True, default='number', + help="Choose the method to use to compute the dates and number of entries.\n" + " * Number of Entries: Fix the number of entries and the time between 2 depreciations.\n" + " * Ending Date: Choose the time between 2 depreciations and the date the depreciations won't go beyond.") + prorata = fields.Boolean(string='Prorata Temporis', + help='Indicates that the first depreciation entry for this asset have to be done from the purchase date instead of the first January / Start date of fiscal year') + depreciation_line_ids = fields.One2many('account.asset.depreciation.line', + 'asset_id', + string='Depreciation Lines', + ) + salvage_value = fields.Float(string='Salvage Value', digits=0, + + help="It is the amount you plan to have that you cannot depreciate.") + invoice_id = fields.Many2one('account.move', string='Invoice', + copy=False) + type = fields.Selection([('sale', 'Sale: Revenue Recognition'), + ('purchase', 'Purchase: Asset')], required=True, index=True, default='purchase') + + + #asset category + account_analytic_id = fields.Many2one('account.analytic.account', + string='Analytic Account', + domain="[('company_id', '=', company_id)]") + account_asset_id = fields.Many2one('account.account', + string='Asset Account', required=True, + domain="[('account_type', '!=', 'asset_receivable'),('account_type', '!=', 'liability_payable'),('account_type', '!=', 'asset_cash'),('account_type', '!=', 'liability_credit_card'),('active', '=', True)]", + help="Account used to record the purchase of the asset at its original price.") + account_depreciation_id = fields.Many2one( + 'account.account', string='Depreciation Account', + required=True, + domain="[('account_type', '!=', 'asset_receivable'),('account_type', '!=', 'liability_payable'),('account_type', '!=', 'asset_cash'),('account_type', '!=', 'liability_credit_card'),('active', '=', True)]", + help="Account used in the depreciation entries, to decrease the asset value.") + account_depreciation_expense_id = fields.Many2one( + 'account.account', string='Expense Account', + required=True, + domain="[('account_type', '!=', 'asset_receivable'),('account_type', '!=','liability_payable'),('account_type', '!=', 'asset_cash'),('account_type', '!=','liability_credit_card'),('active', '=', True)]", + help="Account used in the periodical entries, to record a part of the asset as expense.") + journal_id = fields.Many2one('account.journal', string='Journal', + required=True) + open_asset = fields.Boolean(string='Auto-confirm Assets', + help="Check this if you want to automatically confirm the assets of this category when created by invoices.") + group_entries = fields.Boolean(string='Group Journal Entries', + help="Check this if you want to group the generated entries by categories.") + + def unlink(self): + """ Prevents deletion of assets in 'open' or 'close' state or with posted depreciation entries.""" + for asset in self: + if asset.state in ['open', 'close']: + raise UserError( + _('You cannot delete a document is in %s state.') % ( + asset.state,)) + for depreciation_line in asset.depreciation_line_ids: + if depreciation_line.move_id: + raise UserError(_( + 'You cannot delete a document that contains posted entries.')) + return super(AccountAssetAsset, self).unlink() + + def _get_last_depreciation_date(self): + """ + @param id: ids of a account.asset.asset objects + @return: Returns a dictionary of the effective dates of the last depreciation entry made for given asset ids. If there isn't any, return the purchase date of this asset + """ + self.env.cr.execute(""" + SELECT a.id as id, COALESCE(MAX(m.date),a.date) AS date + FROM account_asset_asset a + LEFT JOIN account_asset_depreciation_line rel ON (rel.asset_id = a.id) + LEFT JOIN account_move m ON (rel.move_id = m.id) + WHERE a.id IN %s + GROUP BY a.id, m.date """, (tuple(self.ids),)) + result = dict(self.env.cr.fetchall()) + return result + + @api.onchange('category_id') + def gross_value(self): + """Update the 'value' field based on the 'price' of the selected 'category_id'.""" + self.value = self.category_id.price + @api.onchange('method') + def onchange_method(self): + if self.depreciation_line_ids: + self.depreciation_line_ids = [(fields.Command.clear())] + + + @api.model + def compute_generated_entries(self, date, asset_type=None): + """Compute generated entries for assets based on the provided date and asset type.""" + # Entries generated : one by grouped category and one by asset from ungrouped category + created_move_ids = [] + type_domain = [] + if asset_type: + type_domain = [('type', '=', asset_type)] + + ungrouped_assets = self.env['account.asset.asset'].search( + type_domain + [('state', '=', 'open'), + ('category_id.group_entries', '=', False)]) + created_move_ids += ungrouped_assets._compute_entries(date, + group_entries=False) + + for grouped_category in self.env['account.asset.category'].search( + type_domain + [('group_entries', '=', True)]): + assets = self.env['account.asset.asset'].search( + [('state', '=', 'open'), + ('category_id', '=', grouped_category.id)]) + created_move_ids += assets._compute_entries(date, + group_entries=True) + return created_move_ids + + def _compute_board_amount(self, sequence, residual_amount, amount_to_depr, + undone_dotation_number, + posted_depreciation_line_ids, total_days, + depreciation_date): + """Compute the depreciation amount for a specific sequence in the asset's depreciation schedule.""" + amount = 0 + if sequence == undone_dotation_number: + amount = residual_amount + else: + if self.method == 'linear': + amount = amount_to_depr / (undone_dotation_number - len( + posted_depreciation_line_ids)) + if self.prorata: + amount = amount_to_depr / self.method_number + if sequence == 1: + if self.method_period % 12 != 0: + date = datetime.strptime(str(self.date), + '%Y-%m-%d') + month_days = \ + calendar.monthrange(date.year, date.month)[1] + days = month_days - date.day + 1 + amount = ( + amount_to_depr / self.method_number) / month_days * days + else: + days = (self.company_id.compute_fiscalyear_dates( + depreciation_date)[ + 'date_to'] - depreciation_date).days + 1 + amount = ( + amount_to_depr / self.method_number) / total_days * days + elif self.method == 'degressive': + amount = residual_amount * self.method_progress_factor + if self.prorata: + if sequence == 1: + if self.method_period % 12 != 0: + date = datetime.strptime(str(self.date), + '%Y-%m-%d') + month_days = \ + calendar.monthrange(date.year, date.month)[1] + days = month_days - date.day + 1 + amount = ( + residual_amount * self.method_progress_factor) / month_days * days + else: + days = (self.company_id.compute_fiscalyear_dates( + depreciation_date)[ + 'date_to'] - depreciation_date).days + 1 + amount = ( + residual_amount * self.method_progress_factor) / total_days * days + return amount + + def _compute_board_undone_dotation_nb(self, depreciation_date, total_days): + """Compute the number of remaining depreciations for an asset based on the depreciation date and total days.""" + undone_dotation_number = self.method_number + if self.method_time == 'end': + end_date = datetime.strptime(str(self.method_end), DF).date() + undone_dotation_number = 0 + while depreciation_date <= end_date: + depreciation_date = date(depreciation_date.year, + depreciation_date.month, + depreciation_date.day) + relativedelta( + months=+self.method_period) + undone_dotation_number += 1 + if self.prorata: + undone_dotation_number += 1 + return undone_dotation_number + + def compute_depreciation_board(self): + """ + Compute the depreciation schedule for the asset based on its current state and parameters. + This method calculates the depreciation amount for each period and generates depreciation entries accordingly. + """ + self.ensure_one() + posted_depreciation_line_ids = self.depreciation_line_ids.filtered( + lambda x: x.move_check).sorted(key=lambda l: l.depreciation_date) + unposted_depreciation_line_ids = self.depreciation_line_ids.filtered( + lambda x: not x.move_check) + + # Remove old unposted depreciation lines. We cannot use unlink() with One2many field + commands = [(2, line_id.id, False) for line_id in + unposted_depreciation_line_ids] + + if self.value_residual != 0.0: + amount_to_depr = residual_amount = self.value_residual + if self.prorata: + # if we already have some previous validated entries, starting date is last entry + method perio + if posted_depreciation_line_ids and \ + posted_depreciation_line_ids[-1].depreciation_date: + last_depreciation_date = datetime.strptime( + posted_depreciation_line_ids[-1].depreciation_date, + DF).date() + depreciation_date = last_depreciation_date + relativedelta( + months=+self.method_period) + else: + depreciation_date = datetime.strptime( + str(self._get_last_depreciation_date()[self.id]), + DF).date() + else: + # depreciation_date = 1st of January of purchase year if annual valuation, 1st of + # purchase month in other cases + if self.method_period >= 12: + if self.company_id.fiscalyear_last_month: + asset_date = date(year=int(self.date.year), + month=int( + self.company_id.fiscalyear_last_month), + day=int( + self.company_id.fiscalyear_last_day)) + relativedelta( + days=1) + \ + relativedelta(year=int( + self.date.year)) # e.g. 2018-12-31 +1 -> 2019 + else: + asset_date = datetime.strptime( + str(self.date)[:4] + '-01-01', DF).date() + else: + asset_date = datetime.strptime(str(self.date)[:7] + '-01', + DF).date() + # if we already have some previous validated entries, starting date isn't 1st January but last entry + method period + if posted_depreciation_line_ids and \ + posted_depreciation_line_ids[-1].depreciation_date: + last_depreciation_date = datetime.strptime(str( + posted_depreciation_line_ids[-1].depreciation_date), + DF).date() + depreciation_date = last_depreciation_date + relativedelta( + months=+self.method_period) + else: + depreciation_date = asset_date + day = depreciation_date.day + month = depreciation_date.month + year = depreciation_date.year + total_days = (year % 4) and 365 or 366 + + undone_dotation_number = self._compute_board_undone_dotation_nb( + depreciation_date, total_days) + + for x in range(len(posted_depreciation_line_ids), + undone_dotation_number): + sequence = x + 1 + amount = self._compute_board_amount(sequence, residual_amount, + amount_to_depr, + undone_dotation_number, + posted_depreciation_line_ids, + total_days, + depreciation_date) + + amount = self.currency_id.round(amount) + if float_is_zero(amount, + precision_rounding=self.currency_id.rounding): + continue + residual_amount -= amount + vals = { + 'amount': amount, + 'asset_id': self.id, + 'sequence': sequence, + 'name': (self.code or '') + '/' + str(sequence), + 'remaining_value': residual_amount if residual_amount >= 0 else 0.0, + 'depreciated_value': self.value - ( + self.salvage_value + residual_amount), + 'depreciation_date': depreciation_date.strftime(DF), + } + commands.append((0, False, vals)) + # Considering Depr. Period as months + depreciation_date = date(year, month, day) + relativedelta( + months=+self.method_period) + day = depreciation_date.day + month = depreciation_date.month + year = depreciation_date.year + + self.write({'depreciation_line_ids': commands}) + last_depr_date = None + if self.depreciation_line_ids: + last_depr_date = max(self.depreciation_line_ids.mapped('depreciation_date')) + if last_depr_date: + self._compute_entries(date=last_depr_date) + return True + + def validate(self): + """Update the state to 'open' and track specific fields based on the asset's method.""" + self.write({'state': 'open'}) + field = [ + 'method', + 'method_number', + 'method_period', + 'method_end', + 'method_progress_factor', + 'method_time', + 'salvage_value', + 'invoice_id', + ] + ref_tracked_fields = self.env['account.asset.asset'].fields_get(field) + if not self.depreciation_line_ids: + self.compute_depreciation_board() + for asset in self: + tracked_fields = ref_tracked_fields.copy() + if asset.method == 'linear': + del (tracked_fields['method_progress_factor']) + if asset.method_time != 'end': + del (tracked_fields['method_end']) + else: + del (tracked_fields['method_number']) + dummy, tracking_value_ids = asset._mail_track(tracked_fields, + dict.fromkeys( + field)) + asset.message_post(subject=_('Asset created'), + tracking_value_ids=tracking_value_ids) + + today_date = fields.Date.context_today(self) + + # Split lines based on depreciation_date + draft_lines = asset.depreciation_line_ids.filtered(lambda l: l.move_id and l.move_id.state == 'draft') + + #Post only entries before today + lines_to_post_now = draft_lines.filtered(lambda l: l.depreciation_date < today_date) + moves_to_post_now = lines_to_post_now.mapped('move_id') + if moves_to_post_now: + moves_to_post_now._post() + + #Set auto_post='at_date' for entries today or later + future_lines = draft_lines.filtered(lambda l: l.depreciation_date >= today_date) + future_moves = future_lines.mapped('move_id') + if future_moves: + future_moves.write({'auto_post': 'at_date'}) + + return True + + + def _get_disposal_moves(self): + """Get the disposal moves for the asset.""" + move_ids = [] + for asset in self: + unposted_depreciation_line_ids = asset.depreciation_line_ids.filtered( + lambda x: not x.move_check) + if unposted_depreciation_line_ids: + old_values = { + 'method_end': asset.method_end, + 'method_number': asset.method_number, + } + + # Remove all unposted depr. lines + commands = [(2, line_id.id, False) for line_id in + unposted_depreciation_line_ids] + + # Create a new depr. line with the residual amount and post it + sequence = len(asset.depreciation_line_ids) - len( + unposted_depreciation_line_ids) + 1 + today = datetime.today().strftime(DF) + vals = { + 'amount': asset.value_residual, + 'asset_id': asset.id, + 'sequence': sequence, + 'name': (asset.code or '') + '/' + str(sequence), + 'remaining_value': 0, + 'depreciated_value': asset.value - asset.salvage_value, + # the asset is completely depreciated + 'depreciation_date': today, + } + commands.append((0, False, vals)) + asset.write( + {'depreciation_line_ids': commands, 'method_end': today, + 'method_number': sequence}) + tracked_fields = self.env['account.asset.asset'].fields_get( + ['method_number', 'method_end']) + changes, tracking_value_ids = asset._mail_track( + tracked_fields, old_values) + + if changes: + asset.message_post(subject=_( + 'Asset sold or disposed. Accounting entry awaiting for validation.'), + tracking_value_ids=tracking_value_ids) + move_ids += asset.depreciation_line_ids[-1].create_move( + post_move=False) + + return move_ids + + def set_to_close(self): + """Set the asset to close state by creating disposal moves and returning an action window to view the move(s).""" + move_ids = self._get_disposal_moves() + if move_ids: + name = _('Disposal Move') + view_mode = 'form' + if len(move_ids) > 1: + name = _('Disposal Moves') + view_mode = 'list,form' + return { + 'name': name, + 'view_mode': view_mode, + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'target': 'current', + 'res_id': move_ids[0], + } + # Fallback, as if we just clicked on the smartbutton + return self.open_entries() + + def set_to_draft(self): + """Set the asset's state to 'draft'.""" + self.write({'state': 'draft'}) + + @api.depends('value', 'salvage_value', 'depreciation_line_ids.move_check', + 'depreciation_line_ids.amount') + def _amount_residual(self): + """Compute the residual value of the asset based on the total depreciation amount.""" + for record in self: + total_amount = 0.0 + for line in record.depreciation_line_ids: + if line.move_check: + total_amount += line.amount + record.value_residual = record.value - total_amount - record.salvage_value + + @api.onchange('company_id') + def onchange_company_id(self): + """Update the 'currency_id' field based on the selected 'company_id'.""" + self.currency_id = self.company_id.currency_id.id + + @api.depends('depreciation_line_ids.move_id') + def _entry_count(self): + """Compute the number of entries related to the asset based on the depreciation lines.""" + for asset in self: + res = self.env['account.asset.depreciation.line'].search_count( + [('asset_id', '=', asset.id), ('move_id', '!=', False)]) + asset.entry_count = res or 0 + + @api.constrains('prorata', 'method_time') + def _check_prorata(self): + """Check if prorata temporis can be applied for the given asset based on the 'prorata' and 'method_time' fields.""" + if self.prorata and self.method_time != 'number': + raise ValidationError(_( + 'Prorata temporis can be applied only for time method "number of depreciations".')) + + @api.onchange('category_id') + def onchange_category_id(self): + """Update the fields of the asset based on the selected 'category_id'.""" + vals = self.onchange_category_id_values(self.category_id.id) + # We cannot use 'write' on an object that doesn't exist yet + if vals: + for k, v in vals['value'].items(): + setattr(self, k, v) + + def onchange_category_id_values(self, category_id): + """Update the fields of the asset based on the selected 'category_id'.""" + if category_id: + category = self.env['account.asset.category'].browse(category_id) + return { + 'value': { + 'method': category.method, + 'method_number': category.method_number, + 'method_time': category.method_time, + 'method_period': category.method_period, + 'method_progress_factor': category.method_progress_factor, + 'method_end': category.method_end, + 'prorata': category.prorata, + 'journal_id':category.journal_id.id, + 'account_asset_id':category.account_asset_id.id, + 'account_depreciation_id':category.account_depreciation_id.id, + 'account_depreciation_expense_id':category.account_depreciation_expense_id.id, + 'account_analytic_id':category.account_analytic_id.id + } + } + + @api.onchange('method_time') + def onchange_method_time(self): + """Update the 'prorata' field based on the selected 'method_time' value.""" + if self.method_time != 'number': + self.prorata = False + + def copy_data(self, default=None): + """Copies the data of the current record with the option to override default values.""" + if default is None: + default = {} + default['name'] = self.name + _(' (copy)') + return super(AccountAssetAsset, self).copy_data(default) + + def _compute_entries(self, date, group_entries=False): + """Compute depreciation entries for the given date.""" + depreciation_ids = self.env['account.asset.depreciation.line'].search([ + ('asset_id', 'in', self.ids), ('depreciation_date', '<=', date), + ('move_check', '=', False)]) + if group_entries: + return depreciation_ids.create_grouped_move() + return depreciation_ids.create_move() + + def open_entries(self): + """Return a dictionary to open journal entries related to the asset.""" + move_ids = [] + for asset in self: + for depreciation_line in asset.depreciation_line_ids: + if depreciation_line.move_id: + move_ids.append(depreciation_line.move_id.id) + return { + 'name': _('Journal Entries'), + 'view_mode': 'list,form', + 'res_model': 'account.move', + 'views': [(self.env.ref('account.view_move_tree').id, 'list'), (False, 'form')], + 'view_id': False, + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', move_ids)], + } + + def action_save_model(self): + return{ + 'type': 'ir.actions.act_window', + 'name': _('Asset Model'), + 'res_model': 'account.asset.category', + 'view_mode': 'form', + 'target': 'current', + 'context': {'default_price': self.value, + 'default_method_time':self.method_time, + 'default_method_end':self.method_end, + 'default_method_number':self.method_number, + 'default_method_period':self.method_period, + 'default_method':self.method, + 'default_company_id':self.company_id.id, + 'default_method_progress_factor':self.method_progress_factor, + 'default_prorata':self.prorata, + 'default_group_entries':self.group_entries, + 'default_open_asset':self.open_asset, + 'default_account_analytic_id':self.account_analytic_id.id, + 'default_account_depreciation_expense_id':self.account_depreciation_expense_id.id, + 'default_account_depreciation_id':self.account_depreciation_id.id, + 'default_account_asset_id':self.account_asset_id.id, + 'default_journal_id':self.journal_id.id, + 'default_asset_id': self.id, + } + } + + def action_cancel_assets(self): + for asset in self: + for move in asset.depreciation_line_ids.mapped('move_id'): + if move.state == 'posted': + # Force to draft + move.button_draft() # or move.state = 'draft' if button_draft is restricted + move.unlink() + + # Delete all depreciation lines + asset.depreciation_line_ids.unlink() + + # Reset state + asset.state = 'cancelled'