first push message

This commit is contained in:
2026-07-01 14:41:49 +07:00
parent 6667dec2bf
commit 58b5f46cc4
2951 changed files with 316619 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from . import account_account
from . import account_asset_asset
from . import account_asset_category
from . import account_asset_depreciation_line
from . import account_bank_statement_line
from . import account_followup
from . import account_journal
from . import account_move
from . import account_move_line
from . import account_payment
from . import account_payment_method
from . import account_recurring_entries_line
from . import account_report
from . import followup_line
from . import multiple_invoice
from . import multiple_invoice_layout
from . import product_template
from . import recurring_payments
from . import res_company
from . import res_config_settings
from . import res_partner
from . import sale_order
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import api, fields, models
from odoo.tools.misc import get_lang
class CashFlow(models.Model):
"""Inherits the account.account model to add additional functionality and
fields to the account"""
_inherit = 'account.account'
def get_cash_flow_ids(self):
"""Returns a list of cashflows for the account"""
cash_flow_id = self.env.ref('base_accounting_kit.account_financial_report_cash_flow0')
if cash_flow_id:
return [('parent_id.id', '=', cash_flow_id.id)]
cash_flow_type = fields.Many2one('account.financial.report',
string="Cash Flow type",
domain=get_cash_flow_ids)
@api.onchange('cash_flow_type')
def onchange_cash_flow_type(self):
"""Onchange the cash flow type of the account that will be updating
the account_ids values"""
for rec in self.cash_flow_type:
# update new record
rec.write({
'account_ids': [(4, self._origin.id)]
})
if self._origin.cash_flow_type.ids:
for rec in self._origin.cash_flow_type:
# remove old record
rec.write({'account_ids': [(3, self._origin.id)]})
class AccountCommonJournalReport(models.TransientModel):
"""Model used for creating the common journal report"""
_name = 'account.common.journal.report'
_description = 'Common Journal Report'
_inherit = "account.report"
section_main_report_ids = fields.Many2many(string="Section Of",
comodel_name='account.report',
relation="account_common_journal_report_section_rel",
column1="sub_report_id",
column2="main_report_id")
section_report_ids = fields.Many2many(string="Sections",
comodel_name='account.report',
relation="account_common_journal_report_section_rel",
column1="main_report_id",
column2="sub_report_id")
amount_currency = fields.Boolean(
'With Currency',
help="Print Report with the currency column if the currency differs "
"from the company currency.")
company_id = fields.Many2one('res.company', string='Company',
required=True, readonly=True,
default=lambda self: self.env.company)
date_from = fields.Date(string='Start Date')
date_to = fields.Date(string='End Date')
target_move = fields.Selection([('posted', 'All Posted Entries'),
('all', 'All Entries'),
], string='Target Moves',
required=True, default='posted')
def pre_print_report(self, data):
"""Pre-print the given data and that updates the amount
amount_currency value"""
data['form'].update({'amount_currency': self.amount_currency})
return data
def check_report(self):
"""Function to check if the report comes active models and related values"""
self.ensure_one()
data = {}
data['ids'] = self.env.context.get('active_ids', [])
data['model'] = self.env.context.get('active_model', 'ir.ui.menu')
data['form'] = self.read(['date_from', 'date_to', 'journal_ids', 'target_move', 'company_id'])[0]
used_context = self._build_contexts(data)
data['form']['used_context'] = dict(used_context, lang=get_lang(self.env).code)
return self.with_context(discard_logo_check=True)._print_report(data)
def _build_contexts(self, data):
"""Builds the context information for the given data"""
result = {}
result['journal_ids'] = 'journal_ids' in data['form'] and data['form']['journal_ids'] or False
result['state'] = 'target_move' in data['form'] and data['form']['target_move'] or ''
result['date_from'] = data['form']['date_from'] or False
result['date_to'] = data['form']['date_to'] or False
result['strict_range'] = True if result['date_from'] else False
result['company_id'] = data['form']['company_id'][0] or False
return result
@@ -0,0 +1,623 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
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'
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import api, fields, models
class AccountAssetCategory(models.Model):
_name = 'account.asset.category'
_description = 'Asset category'
active = fields.Boolean(default=True)
name = fields.Char(required=True, index=True, string="Asset Type")
company_id = fields.Many2one('res.company', string='Company',
required=True,
default=lambda self: self.env.company)
price = fields.Monetary(string='Price', required=True)
currency_id = fields.Many2one("res.currency",
default=lambda self: self.env[
'res.currency'].search(
[('name', '=', 'USD')]).id,
readonly=True, hide=True)
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)
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 depreciations needed to depreciate your asset")
method_period = fields.Integer(string='Period Length', default=1,
help="State here the time between 2 depreciations, in months",
required=True)
method_progress_factor = fields.Float('Degressive Factor', default=0.3)
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.")
method_end = fields.Date('Ending date')
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 of January')
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.")
type = fields.Selection([('sale', 'Sale: Revenue Recognition'),
('purchase', 'Purchase: Asset')], required=True,
index=True, default='purchase')
@api.onchange('account_asset_id')
def onchange_account_asset(self):
"""Onchange method triggered when the 'account_asset_id' field is modified.
Updates 'account_depreciation_id' or 'account_depreciation_expense_id' based on the 'type' field value."""
if self.type == "purchase":
self.account_depreciation_id = self.account_asset_id
elif self.type == "sale":
self.account_depreciation_expense_id = self.account_asset_id
@api.onchange('type')
def onchange_type(self):
"""Update the 'prorata' and 'method_period' fields based on the value of the 'type' field."""
if self.type == 'sale':
self.prorata = True
self.method_period = 1
else:
self.method_period = 12
@api.onchange('method_time')
def _onchange_method_time(self):
"""Update the 'prorata' field based on the value of the 'method_time' field.
Set 'prorata' to False if 'method_time' is not equal to 'number'."""
if self.method_time != 'number':
self.prorata = False
@api.model
def create(self, vals):
record = super().create(vals)
asset_id = self.env.context.get('default_asset_id')
if asset_id:
asset = self.env['account.asset.asset'].browse(asset_id)
asset.category_id = record.id
return record
@@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import float_compare
class AccountAssetDepreciationLine(models.Model):
"""Model for managing asset depreciation lines in the accounting system."""
_name = 'account.asset.depreciation.line'
_description = 'Asset depreciation line'
name = fields.Char(string='Depreciation Name', required=True, index=True)
sequence = fields.Integer(required=True)
asset_id = fields.Many2one('account.asset.asset', string='Asset',
required=True, ondelete='cascade')
parent_state = fields.Selection(related='asset_id.state',
string='State of Asset')
amount = fields.Float(string='Current Depreciation',
required=True)
remaining_value = fields.Float(string='Next Period Depreciation',
required=True)
depreciated_value = fields.Float(string='Cumulative Depreciation',
required=True)
depreciation_date = fields.Date('Depreciation Date', index=True)
move_id = fields.Many2one('account.move', string='Depreciation Entry')
move_check = fields.Boolean(compute='_get_move_check', string='Linked',
store=True)
move_posted_check = fields.Boolean(compute='_get_move_posted_check',
string='Posted', store=True)
@api.depends('move_id')
def _get_move_check(self):
"""Compute the 'move_check' field based on the presence of 'move_id'
for each record in the 'AccountAssetDepreciationLine' class."""
for line in self:
line.move_check = bool(line.move_id)
@api.depends('move_id.state')
def _get_move_posted_check(self):
"""Compute the 'move_posted_check' field based on the state of 'move_id'
for each record in the 'AccountAssetDepreciationLine' class."""
for line in self:
line.move_posted_check = True if line.move_id and line.move_id.state == 'posted' else False
def create_move(self, post_move=True):
"""Create accounting moves for asset depreciation lines."""
created_moves = self.env['account.move']
prec = self.env['decimal.precision'].precision_get('Account')
if self.mapped('move_id'):
raise UserError(_(
'This depreciation is already linked to a journal entry! Please post or delete it.'))
for line in self:
asset_id = line.asset_id
depreciation_date = self.env.context.get(
'depreciation_date') or line.depreciation_date or fields.Date.context_today(
self)
company_currency = asset_id.company_id.currency_id
current_currency = asset_id.currency_id
amount = current_currency._convert(line.amount, company_currency,
line.asset_id.company_id,
depreciation_date)
asset_name = line.asset_id.name + ' (%s/%s)' % (line.sequence, len(line.asset_id.depreciation_line_ids))
partner = self.env['res.partner']._find_accounting_partner(line.asset_id.partner_id)
move_line_1 = {
'name': asset_name,
'account_id': asset_id.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,
'journal_id': asset_id.journal_id.id,
'partner_id': partner.id,
'currency_id': company_currency != current_currency and current_currency.id or company_currency.id,
'amount_currency': company_currency != current_currency and - 1.0 * line.amount or 0.0,
}
move_line_2 = {
'name': asset_name,
'account_id': asset_id.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,
'journal_id': asset_id.journal_id.id,
'partner_id': partner.id,
'currency_id': company_currency != current_currency and current_currency.id or company_currency.id,
'amount_currency': company_currency != current_currency and line.amount or 0.0,
}
line_ids = [(0, 0, {
'account_id': asset_id.account_depreciation_id.id,
'partner_id': partner.id,
'credit': amount if float_compare(amount, 0.0,
precision_digits=prec) > 0 else 0.0,
}), (0, 0, {
'account_id': asset_id.account_depreciation_expense_id.id,
'partner_id': partner.id,
'debit': amount if float_compare(amount, 0.0,
precision_digits=prec) > 0 else 0.0,
})]
move = self.env['account.move'].create({
'ref': line.asset_id.code,
'date': depreciation_date or False,
'journal_id': asset_id.journal_id.id,
'line_ids': line_ids,
})
for move_line in move.line_ids:
if move_line.account_id.id == move_line_1['account_id']:
move_line.write({'credit': move_line_1['credit'],
'debit': move_line_1['debit']})
elif move_line.account_id.id == move_line_2['account_id']:
move_line.write({'debit': move_line_2['debit'],
'credit': move_line_2['credit']})
if move.line_ids.filtered(
lambda x: x.name == 'Automatic Balancing Line'):
move.line_ids.filtered(
lambda x: x.name == 'Automatic Balancing Line').unlink()
line.write({'move_id': move.id, 'move_check': True})
created_moves |= move
if post_move and created_moves:
created_moves.filtered(lambda m: any(
m.asset_depreciation_ids.mapped(
'asset_id.open_asset'))).post()
return [x.id for x in created_moves]
def create_grouped_move(self, post_move=True):
"""Create a grouped accounting move for asset depreciation lines."""
if not self.exists():
return []
created_moves = self.env['account.move']
category_id = self[
0].asset_id.category_id # we can suppose that all lines have the same category
depreciation_date = self.env.context.get(
'depreciation_date') or fields.Date.context_today(self)
amount = 0.0
for line in self:
# Sum amount of all depreciation lines
company_currency = line.asset_id.company_id.currency_id
current_currency = line.asset_id.currency_id
amount += current_currency.compute(line.amount, company_currency)
name = category_id.name + _(' (grouped)')
move_line_1 = {
'name': name,
'account_id': category_id.account_depreciation_id.id,
'debit': 0.0,
'credit': amount,
'journal_id': category_id.journal_id.id,
'analytic_account_id': category_id.account_analytic_id.id if category_id.type == 'sale' else False,
}
move_line_2 = {
'name': name,
'account_id': category_id.account_depreciation_expense_id.id,
'credit': 0.0,
'debit': amount,
'journal_id': category_id.journal_id.id,
'analytic_account_id': category_id.account_analytic_id.id if category_id.type == 'purchase' else False,
}
move_vals = {
'ref': category_id.name,
'date': depreciation_date or False,
'journal_id': category_id.journal_id.id,
'line_ids': [(0, 0, move_line_1), (0, 0, move_line_2)],
}
move = self.env['account.move'].create(move_vals)
self.write({'move_id': move.id, 'move_check': True})
created_moves |= move
if post_move and created_moves:
self.post_lines_and_close_asset()
created_moves.post()
return [x.id for x in created_moves]
def post_lines_and_close_asset(self):
# we re-evaluate the assets to determine whether we can close them
# `message_post` invalidates the (whole) cache
# preprocess the assets and lines in which a message should be posted,
# and then post in batch will prevent the re-fetch of the same data over and over.
assets_to_close = self.env['account.asset.asset']
for line in self:
asset = line.asset_id
if asset.currency_id.is_zero(asset.value_residual):
assets_to_close |= asset
self.log_message_when_posted()
assets_to_close.write({'state': 'close'})
for asset in assets_to_close:
asset.message_post(body=_("Document closed."))
def log_message_when_posted(self):
"""Format and post messages for asset depreciation lines that are posted."""
def _format_message(message_description, tracked_values):
message = ''
if message_description:
message = '<span>%s</span>' % message_description
for name, values in tracked_values.items():
message += '<div> &nbsp; &nbsp; &bull; <b>%s</b>: ' % name
message += '%s</div>' % values
return message
# `message_post` invalidates the (whole) cache
# preprocess the assets in which messages should be posted,
# and then post in batch will prevent the re-fetch of the same data over and over.
assets_to_post = {}
for line in self:
if line.move_id and line.move_id.state == 'draft':
partner_name = line.asset_id.partner_id.name
currency_name = line.asset_id.currency_id.name
msg_values = {_('Currency'): currency_name,
_('Amount'): line.amount}
if partner_name:
msg_values[_('Partner')] = partner_name
msg = _format_message(_('Depreciation line posted.'),
msg_values)
assets_to_post.setdefault(line.asset_id, []).append(msg)
for asset, messages in assets_to_post.items():
for msg in messages:
asset.message_post(body=msg)
# def unlink(self):
# """Check if the depreciation line is linked to a posted move before deletion."""
# for record in self:
# if record.move_check:
# if record.asset_id.category_id.type == 'purchase':
# msg = _("You cannot delete posted depreciation lines.")
# else:
# msg = _("You cannot delete posted installment lines.")
# raise UserError(msg)
# return super(AccountAssetDepreciationLine, self).unlink()
@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import api, fields, models
from odoo.http import request
class AccountBankStatementLine(models.Model):
"""Update the 'rowdata' field for the specified record."""
_name = 'account.bank.statement.line'
_inherit = ['account.bank.statement.line', 'mail.thread',
'mail.activity.mixin', 'analytic.mixin']
lines_widget = fields.Char(string="Lines Widget")
account_id = fields.Many2one('account.account', string='Account')
tax_ids = fields.Many2many('account.tax')
form_name = fields.Char()
form_balance = fields.Monetary(currency_field='currency_id')
rowdata = fields.Json(string="RowData")
matchRowdata = fields.Json(string="MatchRowData")
record_id = fields.Integer()
company_currency_id = fields.Many2one(
related='company_id.currency_id', readonly=True,
)
bank_state = fields.Selection(selection=[('invalid', 'Invalid'),
('valid', 'Valid'),
('reconciled', 'Reconciled')],
compute='_compute_state', store=True)
reconcile_models_widget = fields.Char()
lines_widget_json = fields.Json(store=True)
@api.model
def update_rowdata(self, record_id):
"""Update the 'rowdata' field for the specified record."""
request.session['record_id'] = record_id
@api.model
def update_match_row_data(self, resId):
"""Update the match row data for a specific record identified by the given resId."""
request.session['resId'] = resId
move_record = self.env['account.move.line'].browse(resId)
move_record_values = {
'id': move_record.id,
'account_id': move_record.account_id.id,
'account_name': move_record.account_id.name,
'account_code': move_record.account_id.code,
'partner_id': move_record.partner_id,
'partner_name': move_record.partner_id.name,
'date': move_record.date,
'move_id': move_record.move_id,
'move_name': move_record.move_id.name,
'name': move_record.name,
'amount_residual_currency': move_record.amount_residual_currency,
'amount_residual': move_record.amount_residual,
'currency_id': move_record.currency_id.id,
'currency_symbol': move_record.currency_id.symbol
}
return move_record_values
def button_validation(self, async_action=False):
"""Ensure the current recordset holds a single record and mark it as reconciled."""
self.ensure_one()
self.is_reconciled = True
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
def button_reset(self):
"""Reset the current bank statement line if it is in a 'reconciled' state."""
self.ensure_one()
if self.bank_state == 'reconciled':
self.action_undo_reconciliation()
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
def button_to_check(self, async_action=True):
"""Ensure the current recordset holds a single record, validate the bank
state, and mark the move as 'to check'."""
self.ensure_one()
if self.bank_state == 'valid':
self.button_validation(async_action=async_action)
self.move_id.to_check = True
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
def button_set_as_checked(self):
"""Mark the associated move as 'not to check' by setting 'to_check' to False."""
self.ensure_one()
self.move_id.to_check = False
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
@api.model
def get_statement_line(self, record_id):
"""Retrieve and format bank statement line details based on the provided record ID."""
statement_line_records = self.env[
'account.bank.statement.line'].search_read([('id', '=', record_id)])
result_list = []
for record in statement_line_records:
move_id = record.get('move_id', False)
partner_id = record.get('partner_id', False)
date = record.get('date', False)
amount = record.get('amount', False)
currency_id = record.get('currency_id', False)
payment_ref = record.get("payment_ref", False)
bank_state = record.get("bank_state", False)
id = record.get("id", False)
if move_id:
move_record = self.env['account.move.line'].search(
[('move_id', '=', move_id[0])], limit=1)
currency_symbol = self.env['res.currency'].browse(
currency_id[0])
account_id = move_record.account_id
date_str = date.strftime('%Y-%m-%d') if date else None
result_list.append({
'id': id,
'move_id': move_id,
'partner_id': partner_id,
'account_id': account_id.id,
'account_name': account_id.name,
'account_code': account_id.code,
'date': date_str,
'amount': amount,
'currency_symbol': currency_symbol.symbol,
'payment_ref': payment_ref,
'bank_state': bank_state,
})
# Update the account_id for the current record
self.env['account.bank.statement.line'].browse(
record['id']).write({'account_id': account_id.id})
return result_list
@api.depends('account_id')
def _compute_state(self):
"""Compute the state of bank transactions based on the account's
reconciliation status and journal settings."""
for record in self:
if record.is_reconciled:
record.bank_state = 'reconciled'
else:
suspense_account = record.journal_id.suspense_account_id
if suspense_account in record.account_id:
record.bank_state = 'invalid'
else:
record.bank_state = 'valid'
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import fields, models
class Followup(models.Model):
"""Model for managing account follow-ups."""
_name = 'account.followup'
_description = 'Account Follow-up'
_rec_name = 'name'
followup_line_ids = fields.One2many('followup.line', 'followup_id',
'Follow-up', copy=True)
company_id = fields.Many2one('res.company', 'Company',
default=lambda self: self.env.company)
name = fields.Char(related='company_id.name', readonly=True)
@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import fields, models, _
class AccountJournal(models.Model):
"""Module inherited for adding the reconcile method in the account
journal"""
_inherit = "account.journal"
multiple_invoice_ids = fields.One2many('multiple.invoice',
'journal_id',
string='Multiple Invoice')
multiple_invoice_type = fields.Selection(
[('text', 'Text'), ('watermark', 'Watermark')], required=True,
default='text', string="Display Type")
text_position = fields.Selection([
('header', 'Header'),
('footer', 'Footer'),
('body', 'Document Body')
], required=True, default='header', string='Text Position')
body_text_position = fields.Selection([
('tl', 'Top Left'),
('tr', 'Top Right'),
('bl', 'Bottom Left'),
('br', 'Bottom Right'),
], default='tl', string='Body Text Position')
text_align = fields.Selection([
('right', 'Right'),
('left', 'Left'),
('center', 'Center'),
], default='right', string='Center Align Text Position')
layout = fields.Char(string="Layout",
related="company_id.external_report_layout_id.key")
def action_open_reconcile(self):
"""Open the reconciliation view based on the type of the account journal."""
self.ensure_one()
if self.type in ('bank', 'cash'):
views = [
(self.env.ref(
'base_accounting_kit.account_bank_statement_line_view_kanban').id,
'kanban'),
(self.env.ref(
'base_accounting_kit.account_bank_statement_line_view_tree').id,
'list'), # Include tree view
]
context = {
'default_journal_id': self.id,
'search_default_journal_id': self.id,
}
kanban_first = True
name = None
extra_domain = None
return {
'name': name or _("Bank Reconciliation"),
'type': 'ir.actions.act_window',
'res_model': 'account.bank.statement.line',
'context': context,
'search_view_id': [
self.env.ref(
'base_accounting_kit.account_bank_statement_line_view_search').id,
'search'],
'view_mode': 'kanban,list' if kanban_first else 'list,kanban',
'views': views if kanban_first else views[::-1],
'domain': [('state', '!=', 'cancel')] + (extra_domain or []),
'help': _("""
<p class="o_view_nocontent_smiling_face">
Nothing to do here!
</p>
<p>
No transactions matching your filters were found.
</p>
"""),
}
else:
# Open reconciliation view for customers/suppliers
action_context = {'show_mode_selector': False,
'company_ids': self.mapped('company_id').ids}
if self.type == 'sale':
action_context.update({'mode': 'customers'})
elif self.type == 'purchase':
action_context.update({'mode': 'suppliers'})
return {
'type': 'ir.actions.client',
'tag': 'manual_reconciliation_view',
'context': action_context,
}
def action_import_wizard(self):
"""Function to open wizard"""
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'import.bank.statement',
'target': 'new',
'context': {
'default_journal_id': self.id,
}
}
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountMove(models.Model):
"""Inherits from the account.move model for adding the depreciation
field to the account"""
_inherit = 'account.move'
has_due = fields.Boolean(string='Has due')
is_warning = fields.Boolean(string='Is warning')
due_amount = fields.Float(string="Due Amount",
related='partner_id.due_amount')
recurring_ref = fields.Char(string='Recurring Ref')
asset_depreciation_ids = fields.One2many('account.asset.depreciation.line',
'move_id',
string='Assets Depreciation Lines')
to_check = fields.Boolean(string='To Check', tracking=True,
help="If this checkbox is ticked, it means that the user was not sure of all the related "
"information at the time of the creation of the move and that the move needs to be "
"checked again.",
)
def button_cancel(self):
"""Button action to cancel the transfer"""
for move in self:
for line in move.asset_depreciation_ids:
line.move_posted_check = False
return super(AccountMove, self).button_cancel()
def post(self):
"""Supering the post method to mapped the asset depreciation records"""
self.mapped('asset_depreciation_ids').post_lines_and_close_asset()
return super(AccountMove, self).action_post()
@api.model
def _refund_cleanup_lines(self, lines):
"""Supering the refund cleanup lines to check the asset category """
result = super(AccountMove, self)._refund_cleanup_lines(lines)
for i, line in enumerate(lines):
for name, field in line._fields.items():
if name == 'asset_category_id':
result[i][2][name] = False
break
return result
def action_cancel(self):
"""Action perform to cancel the asset record"""
res = super(AccountMove, self).action_cancel()
self.env['account.asset.asset'].sudo().search(
[('invoice_id', 'in', self.ids)]).write({'active': False})
return res
def action_post(self):
"""To check the selected customers due amount is exceed than blocking stage"""
pay_type = ['out_invoice', 'out_refund', 'out_receipt']
for rec in self:
if rec.partner_id.active_limit and rec.move_type in pay_type \
and rec.partner_id.enable_credit_limit:
if rec.due_amount >= rec.partner_id.blocking_stage and rec.partner_id.blocking_stage != 0:
raise UserError(_(
"%s is in Blocking Stage and "
"has a due amount of %s %s to pay") % (
rec.partner_id.name, rec.due_amount,
rec.currency_id.symbol))
result = super(AccountMove, self).action_post()
for inv in self:
context = dict(self.env.context)
# Within the context of an invoice,
# this default value is for the type of the invoice, not the type
# of the asset. This has to be cleaned from the context before
# creating the asset,otherwise it tries to create the asset with
# the type of the invoice.
context.pop('default_type', None)
inv.invoice_line_ids.with_context(context).asset_create()
return result
@api.onchange('partner_id')
def check_due(self):
"""To show the due amount and warning stage"""
if self.partner_id and self.partner_id.due_amount > 0 \
and self.partner_id.active_limit \
and self.partner_id.enable_credit_limit:
self.has_due = True
else:
self.has_due = False
if self.partner_id and self.partner_id.active_limit \
and self.partner_id.enable_credit_limit:
if self.due_amount >= self.partner_id.warning_stage:
if self.partner_id.warning_stage != 0:
self.is_warning = True
else:
self.is_warning = False
@@ -0,0 +1,211 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
import ast
from datetime import datetime
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF
from dateutil.relativedelta import relativedelta
class AccountInvoiceLine(models.Model):
"""Define a model for account invoice lines with fields related to assets and their management."""
_inherit = 'account.move.line'
asset_category_id = fields.Many2one('account.asset.category',
string='Asset Category')
asset_start_date = fields.Date(string='Asset Start Date',
compute='_get_asset_date', readonly=True,
store=True)
asset_end_date = fields.Date(string='Asset End Date',
compute='_get_asset_date', readonly=True,
store=True)
asset_mrr = fields.Float(string='Monthly Recurring Revenue',
compute='_get_asset_date',
readonly=True, digits='Account',
store=True)
@api.depends('asset_category_id', 'move_id.invoice_date')
def _get_asset_date(self):
"""Returns the asset_start_date and the asset_end_date of the Asset"""
for record in self:
record.asset_mrr = 0
record.asset_start_date = False
record.asset_end_date = False
cat = record.asset_category_id
if cat:
if cat.method_number == 0 or cat.method_period == 0:
raise UserError(_(
'The number of depreciations or the period length of '
'your asset category cannot be null.'))
months = cat.method_number * cat.method_period
if record.move_id in ['out_invoice', 'out_refund']:
record.asset_mrr = record.price_subtotal_signed / months
if record.move_id.invoice_date:
start_date = datetime.strptime(
str(record.move_id.invoice_date), DF).replace(day=1)
end_date = (start_date + relativedelta(months=months,
days=-1))
record.asset_start_date = start_date.strftime(DF)
record.asset_end_date = end_date.strftime(DF)
def asset_create(self):
"""Create function for the asset and its associated properties"""
for record in self:
if record.asset_category_id:
vals = {
'name': record.name,
'code': record.move_id.name or False,
'category_id': record.asset_category_id.id,
'value': record.price_subtotal,
'partner_id': record.partner_id.id,
'company_id': record.move_id.company_id.id,
'currency_id': record.move_id.company_currency_id.id,
'date': record.move_id.invoice_date,
'invoice_id': record.move_id.id,
}
changed_vals = record.env[
'account.asset.asset'].onchange_category_id_values(
vals['category_id'])
vals.update(changed_vals['value'])
asset = record.env['account.asset.asset'].create(vals)
if record.asset_category_id.open_asset:
asset.validate()
return True
@api.depends('asset_category_id')
def onchange_asset_category_id(self):
"""On change function based on the category and its updates the
account status"""
if self.move_id.move_type == 'out_invoice' and self.asset_category_id:
self.account_id = self.asset_category_id.account_asset_id.id
elif self.move_id.move_type == 'in_invoice' and self.asset_category_id:
self.account_id = self.asset_category_id.account_asset_id.id
@api.onchange('product_id')
def _onchange_uom_id(self):
"""Onchange function for product that's call the UOM compute function
and the asset category function"""
result = super(AccountInvoiceLine, self)._compute_product_uom_id()
self.onchange_asset_category_id()
return result
@api.depends('product_id')
def _onchange_product_id(self):
"""Onchange product values and it's associated with the move types"""
vals = super(AccountInvoiceLine, self)._compute_price_unit()
if self.product_id:
if self.move_id.move_type == 'out_invoice':
self.asset_category_id = self.product_id.product_tmpl_id.deferred_revenue_category_id
elif self.move_id.move_type == 'in_invoice':
self.asset_category_id = self.product_id.product_tmpl_id.asset_category_id
return vals
def _set_additional_fields(self, invoice):
"""The function adds additional fields that based on the invoice
move types"""
if not self.asset_category_id:
if invoice.type == 'out_invoice':
self.asset_category_id = self.product_id.product_tmpl_id.deferred_revenue_category_id.id
elif invoice.type == 'in_invoice':
self.asset_category_id = self.product_id.product_tmpl_id.asset_category_id.id
self.onchange_asset_category_id()
super(AccountInvoiceLine, self)._set_additional_fields(invoice)
def get_invoice_line_account(self, type, product, fpos, company):
""""It returns the invoice line and callback"""
return product.asset_category_id.account_asset_id or super(
AccountInvoiceLine, self).get_invoice_line_account(type, product,
fpos, company)
@api.model
def _query_get(self, domain=None):
"""Used to add domain constraints to the query"""
self.check_access_rights('read')
context = dict(self._context or {})
domain = domain or []
if not isinstance(domain, (list, tuple)):
domain = ast.literal_eval(domain)
date_field = 'date'
if context.get('aged_balance'):
date_field = 'date_maturity'
if context.get('date_to'):
domain += [(date_field, '<=', context['date_to'])]
if context.get('date_from'):
if not context.get('strict_range'):
domain += ['|', (date_field, '>=', context['date_from']),
('account_id.include_initial_balance', '=', True)]
elif context.get('initial_bal'):
domain += [(date_field, '<', context['date_from'])]
else:
domain += [(date_field, '>=', context['date_from'])]
if context.get('journal_ids'):
domain += [('journal_id', 'in', context['journal_ids'])]
state = context.get('state')
if state and state.lower() != 'all':
domain += [('parent_state', '=', state)]
if context.get('company_id'):
domain += [('company_id', '=', context['company_id'])]
elif context.get('allowed_company_ids'):
domain += [('company_id', 'in', self.env.companies.ids)]
else:
domain += [('company_id', '=', self.env.company.id)]
if context.get('reconcile_date'):
domain += ['|', ('reconciled', '=', False), '|',
('matched_debit_ids.max_date', '>', context['reconcile_date']),
('matched_credit_ids.max_date', '>', context['reconcile_date'])]
if context.get('account_tag_ids'):
domain += [('account_id.tag_ids', 'in', context['account_tag_ids'].ids)]
if context.get('account_ids'):
domain += [('account_id', 'in', context['account_ids'].ids)]
if context.get('analytic_tag_ids'):
domain += [('analytic_tag_ids', 'in', context['analytic_tag_ids'].ids)]
if context.get('analytic_account_ids'):
domain += [('analytic_account_id', 'in', context['analytic_account_ids'].ids)]
if context.get('partner_ids'):
domain += [('partner_id', 'in', context['partner_ids'].ids)]
if context.get('partner_categories'):
domain += [('partner_id.category_id', 'in', context['partner_categories'].ids)]
where_clause = ""
where_clause_params = []
tables = ''
if domain:
domain.append(('display_type', 'not in', ('line_section', 'line_note')))
domain.append(('parent_state', '!=', 'cancel'))
query = self._search(domain, bypass_access=True)
tables, from_params = query.from_clause
where_clause, where_params = query.where_clause
where_clause_params = from_params + where_params
return tables, where_clause, where_clause_params
@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import fields, models, _
from odoo.exceptions import UserError
class AccountRegisterPayments(models.TransientModel):
"""Inherits the account.payment.register model to add the new
fields and functions"""
_inherit = "account.payment.register"
bank_reference = fields.Char(string="Bank Reference", copy=False)
cheque_reference = fields.Char(string="Cheque Reference", copy=False)
effective_date = fields.Date('Effective Date',
help='Effective date of PDC', copy=False,
default=False)
def _prepare_payment_vals(self, invoices):
"""Its prepare the payment values for the invoice and update
the MultiPayment"""
res = super(AccountRegisterPayments, self)._prepare_payment_vals(
invoices)
# Check payment method is Check or PDC
check_pdc_ids = self.env['account.payment.method'].search(
[('code', 'in', ['pdc', 'check_printing'])])
if self.payment_method_id.id in check_pdc_ids.ids:
currency_id = self.env['res.currency'].browse(res['currency_id'])
journal_id = self.env['account.journal'].browse(res['journal_id'])
# Updating values in case of Multi payments
res.update({
'bank_reference': self.bank_reference,
'cheque_reference': self.cheque_reference,
'check_manual_sequencing': journal_id.check_manual_sequencing,
'effective_date': self.effective_date,
'check_amount_in_words': currency_id.amount_to_text(
res['amount']),
})
return res
def _create_payment_vals_from_wizard(self, batch_result):
"""It super the wizard action of the create payment values and update
the bank and cheque values"""
res = super(AccountRegisterPayments,
self)._create_payment_vals_from_wizard(
batch_result)
if self.effective_date:
res.update({
'bank_reference': self.bank_reference,
'cheque_reference': self.cheque_reference,
'effective_date': self.effective_date,
})
return res
def _create_payment_vals_from_batch(self, batch_result):
"""It super the batch action of the create payment values and update
the bank and cheque values"""
res = super(AccountRegisterPayments,
self)._create_payment_vals_from_batch(
batch_result)
if self.effective_date:
res.update({
'bank_reference': self.bank_reference,
'cheque_reference': self.cheque_reference,
'effective_date': self.effective_date,
})
return res
def _create_payments(self):
"""USed to create a list of payments and update the bank and
cheque reference"""
payments = super(AccountRegisterPayments, self)._create_payments()
for payment in payments:
payment.write({
'bank_reference': self.bank_reference,
'cheque_reference': self.cheque_reference
})
return payments
class AccountPayment(models.Model):
"""It inherits the account.payment model for adding new fields
and functions"""
_inherit = "account.payment"
bank_reference = fields.Char(string="Bank Reference", copy=False)
cheque_reference = fields.Char(string="Cheque Reference",copy=False)
effective_date = fields.Date('Effective Date',
help='Effective date of PDC', copy=False,
default=False)
def open_payment_matching_screen(self):
"""Open reconciliation view for customers/suppliers"""
move_line_id = False
for move_line in self.line_ids:
if move_line.account_id.reconcile:
move_line_id = move_line.id
break
if not self.partner_id:
raise UserError(_("Payments without a customer can't be matched"))
action_context = {'company_ids': [self.company_id.id], 'partner_ids': [
self.partner_id.commercial_partner_id.id]}
if self.partner_type == 'customer':
action_context.update({'mode': 'customers'})
elif self.partner_type == 'supplier':
action_context.update({'mode': 'suppliers'})
if move_line_id:
action_context.update({'move_line_id': move_line_id})
return {
'type': 'ir.actions.client',
'tag': 'manual_reconciliation_view',
'context': action_context,
}
def print_checks(self):
""" Check that the recordset is valid, set the payments state to
sent and call print_checks() """
# Since this method can be called via a client_action_multi, we
# need to make sure the received records are what we expect
selfs = self.filtered(lambda r:
r.payment_method_id.code
in ['check_printing', 'pdc']
and r.state != 'reconciled')
if len(selfs) == 0:
raise UserError(_(
"Payments to print as a checks must have 'Check' "
"or 'PDC' selected as payment method and "
"not have already been reconciled"))
if any(payment.journal_id != selfs[0].journal_id for payment in selfs):
raise UserError(_(
"In order to print multiple checks at once, they "
"must belong to the same bank journal."))
if not selfs[0].journal_id.check_manual_sequencing:
# The wizard asks for the number printed on the first
# pre-printed check so payments are attributed the
# number of the check the'll be printed on.
last_printed_check = selfs.search([
('journal_id', '=', selfs[0].journal_id.id),
('check_number', '!=', "0")], order="check_number desc",
limit=1)
next_check_number = last_printed_check and int(
last_printed_check.check_number) + 1 or 1
return {
'name': _('Print Pre-numbered Checks'),
'type': 'ir.actions.act_window',
'res_model': 'print.prenumbered.checks',
'view_mode': 'form',
'target': 'new',
'context': {
'payment_ids': self.ids,
'default_next_check_number': next_check_number,
}
}
else:
self.filtered(lambda r: r.state == 'draft').post()
self.write({'state': 'sent'})
return self.do_print_checks()
def _prepare_payment_moves(self):
""" supered function to set effective date """
res = super(AccountPayment, self)._prepare_payment_moves()
inbound_pdc_id = self.env.ref(
'base_accounting_kit.account_payment_method_pdc_in').id
outbound_pdc_id = self.env.ref(
'base_accounting_kit.account_payment_method_pdc_out').id
if self.payment_method_id.id == inbound_pdc_id or \
self.payment_method_id.id == outbound_pdc_id \
and self.effective_date:
res[0]['date'] = self.effective_date
for line in res[0]['line_ids']:
line[2]['date_maturity'] = self.effective_date
return res
def mark_as_sent(self):
"""Updates the is_move_sent value of the payment model"""
self.write({'is_sent': True})
def unmark_as_sent(self):
"""Updates the is_move_sent value of the payment model"""
self.write({'is_sent': False})
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import api, models
class AccountPaymentMethod(models.Model):
"""The class inherits the account payment method for supering the
_get_payment_method_information function"""
_inherit = "account.payment.method"
@api.model
def _get_payment_method_information(self):
"""Super the function to update the pdc values"""
res = super()._get_payment_method_information()
res['pdc'] = {'mode': 'multi', 'domain': [('type', '=', 'bank')]}
return res
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import fields, models
class GetAllRecurringEntries(models.TransientModel):
"""Model for managing account recurring entries lines."""
_name = 'account.recurring.entries.line'
_description = 'Account Recurring Entries Line'
date = fields.Date('Date')
template_name = fields.Char('Name')
amount = fields.Float('Amount')
tmpl_id = fields.Many2one('account.recurring.payments', string='id')
@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import api, fields, models
from odoo.tools import get_lang
class AccountCommonReport(models.Model):
"""Inherits the Account report model to add special fields and functions"""
_inherit = "account.report"
_description = "Account Common Report"
company_id = fields.Many2one('res.company', string='Company',
required=True, readonly=True,
default=lambda self: self.env.company)
journal_ids = fields.Many2many(
comodel_name='account.journal',
string='Journals',
required=True,
default=lambda self: self.env['account.journal'].search([('company_id', '=', self.company_id.id)]),
domain="[('company_id', '=', company_id)]")
date_from = fields.Date(string='Start Date')
date_to = fields.Date(string='End Date')
target_move = fields.Selection([('posted', 'All Posted Entries'),
('all', 'All Entries'),
], string='Target Moves',
required=True, default='posted')
@api.onchange('company_id')
def _onchange_company_id(self):
"""Onchange function based on the company and updated the journals"""
if self.company_id:
self.journal_ids = self.env['account.journal'].search(
[('company_id', '=', self.company_id.id)])
else:
self.journal_ids = self.env['account.journal'].search([])
def _build_contexts(self, data):
"""Builds the context information for the given data"""
result = {}
result['journal_ids'] = 'journal_ids' in data['form'] and data['form']['journal_ids'] or False
result['state'] = 'target_move' in data['form'] and data['form']['target_move'] or ''
result['date_from'] = data['form']['date_from'] or False
result['date_to'] = data['form']['date_to'] or False
result['strict_range'] = True if result['date_from'] else False
result['company_id'] = data['form']['company_id'][0] or False
return result
def _print_report(self, data):
"""Raise an error if the report comes checked """
raise NotImplementedError()
def check_report(self):
"""Function to check if the report comes active models and related
values"""
self.ensure_one()
data = {}
data['ids'] = self.env.context.get('active_ids', [])
data['model'] = self.env.context.get('active_model', 'ir.ui.menu')
data['form'] = self.read(['date_from', 'date_to', 'journal_ids', 'target_move', 'company_id'])[0]
used_context = self._build_contexts(data)
data['form']['used_context'] = dict(used_context, lang=get_lang(self.env).code)
return self.with_context(discard_logo_check=True)._print_report(data)
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import fields, models
class FollowupLine(models.Model):
"""Model for defining follow-up criteria including the action name, sequence order, due days, and related follow-ups."""
_name = 'followup.line'
_description = 'Follow-up Criteria'
_order = 'delay'
name = fields.Char('Follow-Up Action', required=True, translate=True)
sequence = fields.Integer(
help="Gives the sequence order when displaying a list of follow-up lines.")
delay = fields.Integer('Due Days', required=True,
help="The number of days after the due date of the invoice"
" to wait before sending the reminder."
" Could be negative if you want to send a polite alert beforehand.")
followup_id = fields.Many2one('account.followup', 'Follow Ups',
ondelete="cascade")
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import fields, models
class MultipleInvoice(models.Model):
"""Multiple Invoice Model"""
_name = "multiple.invoice"
_description = 'Multiple Invoice'
_order = "sequence"
sequence = fields.Integer(string='Sequence No')
copy_name = fields.Char(string='Invoice Copy Name')
journal_id = fields.Many2one('account.journal', string="Journal")
@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import api, fields, models
from odoo.tools.misc import file_path
try:
import sass as libsass
except ImportError:
libsass = None
class MultipleInvoiceLayout(models.TransientModel):
"""
Customise the invoice copy document layout and display a live preview
"""
_name = 'multiple.invoice.layout'
_description = 'Multiple Invoice Document Layout'
def _get_default_journal(self):
"""The default function to return the journal for the invoice"""
return self.env['account.journal'].search(
[('id', '=', self.env.context.get('active_id'))]).id
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company, required=True)
layout = fields.Char(related="company_id.external_report_layout_id.key")
journal_id = fields.Many2one('account.journal', string='Journal',
required=True, default=_get_default_journal)
multiple_invoice_type = fields.Selection(
related='journal_id.multiple_invoice_type', readonly=False,
required=True)
text_position = fields.Selection(related='journal_id.text_position',
readonly=False, required=True,
default='header')
body_text_position = fields.Selection(
related='journal_id.body_text_position',
readonly=False)
text_align = fields.Selection(
related='journal_id.text_align',
readonly=False)
preview = fields.Html(compute='_compute_preview',
sanitize=False,
sanitize_tags=False,
sanitize_attributes=False,
sanitize_style=False,
sanitize_form=False,
strip_style=False,
strip_classes=False)
@api.depends('multiple_invoice_type', 'text_position', 'body_text_position',
'text_align')
def _compute_preview(self):
""" compute a qweb based preview to display on the wizard """
styles = self._get_asset_style()
for wizard in self:
if wizard.company_id:
preview_css = self._get_css_for_preview(styles, wizard.id)
layout = self._get_layout_for_preview()
ir_ui_view = wizard.env['ir.ui.view']
wizard.preview = ir_ui_view._render_template(
'base_accounting_kit.multiple_invoice_wizard_preview',
{'company': wizard.company_id, 'preview_css': preview_css,
'layout': layout,
'mi_type': self.multiple_invoice_type,
'txt_position': self.text_position,
'body_txt_position': self.body_text_position,
'txt_align': self.text_align,
'mi': self.env.ref(
'base_accounting_kit.multiple_invoice_sample_name')
})
else:
wizard.preview = False
def _get_asset_style(self):
"""Used to set the asset style"""
company_styles = self.env['ir.qweb']._render(
'web.styles_company_report', {
'company_ids': self.company_id,
}, raise_if_not_found=False)
return company_styles
@api.model
def _get_css_for_preview(self, scss, new_id):
"""
Compile the scss into css.
"""
css_code = self._compile_scss(scss)
return css_code
@api.model
def _compile_scss(self, scss_source):
"""
This code will compile valid scss into css.
Parameters are the same from odoo/addons/base/models/assetsbundle.py
Simply copied and adapted slightly
"""
# No scss ? still valid, returns empty css
if not scss_source.strip():
return ""
precision = 8
output_style = 'expanded'
bootstrap_path = file_path('web', 'static', 'lib', 'bootstrap',
'scss')
try:
return libsass.compile(
string=scss_source,
include_paths=[
bootstrap_path,
],
output_style=output_style,
precision=precision,
)
except libsass.CompileError as e:
raise libsass.CompileError(e.args[0])
def _get_layout_for_preview(self):
"""Returns the layout Preview for the accounting module"""
if self.layout == 'web.external_layout_boxed':
new_layout = 'base_accounting_kit.boxed'
elif self.layout == 'web.external_layout_bold':
new_layout = 'base_accounting_kit.bold'
elif self.layout == 'web.external_layout_striped':
new_layout = 'base_accounting_kit.striped'
else:
new_layout = 'base_accounting_kit.standard'
return new_layout
def document_layout_save(self):
"""meant to be overridden document_layout_save"""
return self.env.context.get('report_action') or {
'type': 'ir.actions.act_window_close'}
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import fields, models
class ProductTemplate(models.Model):
"""Inherited the model for adding new fields and functions"""
_inherit = 'product.template'
asset_category_id = fields.Many2one('account.asset.category',
string='Asset Type',
company_dependent=True,
ondelete="restrict")
deferred_revenue_category_id = fields.Many2one('account.asset.category',
string='Deferred Revenue Type',
company_dependent=True,
ondelete="restrict")
def _get_asset_accounts(self):
"""Override method to customize asset accounts based on asset and deferred revenue categories."""
res = super(ProductTemplate, self)._get_asset_accounts()
if self.asset_category_id:
res['stock_input'] = self.property_account_expense_id
if self.deferred_revenue_category_id:
res['stock_output'] = self.property_account_income_id
return res
@@ -0,0 +1,159 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from datetime import datetime, date
from dateutil.relativedelta import relativedelta
from odoo import api, models, fields
class RecurringPayments(models.Model):
"""Created the module for recurring payments"""
_name = 'account.recurring.payments'
_description = 'Accounting Recurring Payment'
def _get_next_schedule(self):
"""Function for adding the schedule process"""
if self.date:
recurr_dates = []
today = datetime.today()
start_date = datetime.strptime(str(self.date), '%Y-%m-%d')
while start_date <= today:
recurr_dates.append(str(start_date.date()))
if self.recurring_period == 'days':
start_date += relativedelta(days=self.recurring_interval)
elif self.recurring_period == 'weeks':
start_date += relativedelta(weeks=self.recurring_interval)
elif self.recurring_period == 'months':
start_date += relativedelta(months=self.recurring_interval)
else:
start_date += relativedelta(years=self.recurring_interval)
self.next_date = start_date.date()
name = fields.Char(string='Name')
debit_account = fields.Many2one('account.account', 'Debit Account',
required=True)
credit_account = fields.Many2one('account.account', 'Credit Account',
required=True)
journal_id = fields.Many2one('account.journal', 'Journal', required=True)
analytic_account_id = fields.Many2one('account.analytic.account',
'Analytic Account')
date = fields.Date('Starting Date', required=True, default=date.today())
next_date = fields.Date('Next Schedule', compute=_get_next_schedule,
readonly=True, copy=False)
recurring_period = fields.Selection(selection=[('days', 'Days'),
('weeks', 'Weeks'),
('months', 'Months'),
('years', 'Years')],
store=True, required=True)
amount = fields.Float('Amount')
description = fields.Text('Description')
state = fields.Selection(selection=[('draft', 'Draft'),
('running', 'Running')],
default='draft', string='Status')
journal_state = fields.Selection(selection=[('draft', 'Unposted'),
('posted', 'Posted')],
required=True, default='draft',
string='Generate Journal As')
recurring_interval = fields.Integer('Recurring Interval', default=1)
partner_id = fields.Many2one('res.partner', 'Partner')
pay_time = fields.Selection(selection=[('pay_now', 'Pay Directly'),
('pay_later', 'Pay Later')],
store=True, required=True)
company_id = fields.Many2one('res.company',
default=lambda l: l.env.company.id)
recurring_lines = fields.One2many('account.recurring.entries.line', 'tmpl_id')
@api.onchange('partner_id')
def onchange_partner_id(self):
"""Onchange partner field for updating the credit account value"""
if self.partner_id.property_account_receivable_id:
self.credit_account = self.partner_id.property_account_payable_id
@api.model
def _cron_generate_entries(self):
"""Generate recurring entries based on the defined schedule
and create corresponding accounting moves."""
data = self.env['account.recurring.payments'].search(
[('state', '=', 'running')])
entries = self.env['account.move'].search(
[('recurring_ref', '!=', False)])
journal_dates = []
journal_codes = []
remaining_dates = []
for entry in entries:
journal_dates.append(str(entry.date))
if entry.recurring_ref:
journal_codes.append(str(entry.recurring_ref))
today = datetime.today()
for line in data:
if line.date:
recurr_dates = []
start_date = datetime.strptime(str(line.date), '%Y-%m-%d')
while start_date <= today:
recurr_dates.append(str(start_date.date()))
if line.recurring_period == 'days':
start_date += relativedelta(
days=line.recurring_interval)
elif line.recurring_period == 'weeks':
start_date += relativedelta(
weeks=line.recurring_interval)
elif line.recurring_period == 'months':
start_date += relativedelta(
months=line.recurring_interval)
else:
start_date += relativedelta(
years=line.recurring_interval)
for rec in recurr_dates:
recurr_code = str(line.id) + '/' + str(rec)
if recurr_code not in journal_codes:
remaining_dates.append({
'date': rec,
'template_name': line.name,
'amount': line.amount,
'tmpl_id': line.id,
})
child_ids = self.recurring_lines.create(remaining_dates)
for line in child_ids:
tmpl_id = line.tmpl_id
recurr_code = str(tmpl_id.id) + '/' + str(line.date)
line_ids = [(0, 0, {
'account_id': tmpl_id.credit_account.id,
'partner_id': tmpl_id.partner_id.id,
'credit': line.amount,
# 'analytic_account_id': tmpl_id.analytic_account_id.id,
}), (0, 0, {
'account_id': tmpl_id.debit_account.id,
'partner_id': tmpl_id.partner_id.id,
'debit': line.amount,
# 'analytic_account_id': tmpl_id.analytic_account_id.id,
})]
vals = {
'date': line.date,
'recurring_ref': recurr_code,
'company_id': self.env.company.id,
'journal_id': tmpl_id.journal_id.id,
'ref': line.template_name,
'narration': 'Recurring entry',
'line_ids': line_ids
}
move_id = self.env['account.move'].create(vals)
if tmpl_id.journal_state == 'posted':
move_id.post()
+109
View File
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from datetime import datetime
import calendar
from odoo import models, api, _
from odoo.exceptions import RedirectWarning
class ResCompany(models.Model):
"""Model for inheriting res_company."""
_inherit = "res.company"
@api.model_create_multi
def create(self, vals_list):
"""Ensure fiscal year day does not exceed the maximum valid day for the selected month during record creation."""
for vals in vals_list:
if 'fiscalyear_last_month' in vals and 'fiscalyear_last_day' in vals:
month = vals.get('fiscalyear_last_month')
day = vals.get('fiscalyear_last_day')
if month and day:
if vals.account_opening_date:
year = vals.account_opening_date.year
else:
year = datetime.now().year
max_day = calendar.monthrange(year, int(month))[1]
if int(day) > max_day:
vals['fiscalyear_last_day'] = max_day
return super(ResCompany, self).create(vals_list)
def write(self, vals):
"""Auto-correct fiscal year day to a valid value when month or day is updated to prevent invalid calendar dates."""
if 'fiscalyear_last_month' in vals or 'fiscalyear_last_day' in vals:
month = vals.get('fiscalyear_last_month')
day = vals.get('fiscalyear_last_day')
if month:
if self.account_opening_date:
year = self.account_opening_date.year
else:
year = datetime.now().year
max_day = calendar.monthrange(year, int(month))[1]
if not day:
if any(company.fiscalyear_last_day > max_day for company in self):
vals['fiscalyear_last_day'] = max_day
elif int(day) > max_day:
vals['fiscalyear_last_day'] = max_day
return super(ResCompany, self).write(vals)
def _validate_locks(self, values):
"""Validate the hard lock date by checking for unposted entries and unreconciled bank statement lines."""
if values.get('hard_lock_date'):
draft_entries = self.env['account.move'].search([
('company_id', 'in', self.ids),
('state', '=', 'draft'),
('date', '<=', values['hard_lock_date'])])
if draft_entries:
error_msg = _('There are still unposted entries in the '
'period you want to lock. You should either post '
'or delete them.')
action_error = {
'view_mode': 'list',
'name': 'Unposted Entries',
'res_model': 'account.move',
'type': 'ir.actions.act_window',
'domain': [('id', 'in', draft_entries.ids)],
'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'],
'views': [[self.env.ref('account.view_move_tree').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']],
}
raise RedirectWarning(error_msg, action_error, _('Show unposted entries'))
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
('company_id', 'in', self.ids),
('is_reconciled', '=', False),
('date', '<=', values['hard_lock_date']),
('move_id.state', 'in', ('draft', 'posted')),
])
if unreconciled_statement_lines:
error_msg = _("There are still unreconciled bank statement lines in the period you want to lock."
"You should either reconcile or delete them.")
action_error = {
'view_mode': 'kanban',
'name': 'Unreconciled Transactions',
'res_model': 'account.bank.statement.line',
'type': 'ir.actions.act_window',
'domain': [('id', 'in', unreconciled_statement_lines.ids)],
'views': [[self.env.ref(
'base_accounting_kit.account_bank_statement_line_view_kanban').id,
'kanban']]
}
raise RedirectWarning(error_msg, action_error, _('Show Unreconciled Bank Statement Lines'))
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import models, fields, api
class ResConfigSettings(models.TransientModel):
"""Defines a model for configuration settings with additional fields for
managing customer credit limit and Anglo-Saxon accounting settings."""
_inherit = 'res.config.settings'
customer_credit_limit = fields.Boolean(string="Customer Credit Limit")
use_anglo_saxon_accounting = fields.Boolean(string="Use Anglo-Saxon accounting", readonly=False,
related='company_id.anglo_saxon_accounting')
fiscalyear_last_day = fields.Integer(
related='company_id.fiscalyear_last_day', readonly=False
)
fiscalyear_last_month = fields.Selection(
related='company_id.fiscalyear_last_month', readonly=False
)
@api.model
def get_values(self):
"""Retrieve the values for configuration settings including the
customer credit limit from the database parameters. """
res = super(ResConfigSettings, self).get_values()
params = self.env['ir.config_parameter'].sudo()
customer_credit_limit = params.get_param('customer_credit_limit',
default=False)
res.update(customer_credit_limit=customer_credit_limit)
return res
def set_values(self):
"""Set the customer credit limit value in the database parameters using superuser access."""
super(ResConfigSettings, self).set_values()
self.env['ir.config_parameter'].sudo().set_param(
"customer_credit_limit",
self.customer_credit_limit)
@api.model
def get_view_id(self):
"""Retrieve the ID of the view for bank reconciliation widget form."""
view_id = self.env['ir.model.data']._xmlid_to_res_id(
'base_accounting_kit.view_bank_reconcile_widget_form')
return view_id
+528
View File
@@ -0,0 +1,528 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from datetime import date, timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
import base64
import io
import json
import xlsxwriter
from odoo.exceptions import ValidationError, UserError
from odoo.tools.json import json_default
class ResPartner(models.Model):
"""Inheriting res.partner"""
_inherit = "res.partner"
invoice_list = fields.One2many('account.move', 'partner_id',
string="Invoice Details",
readonly=True,
domain=(
[('payment_state', '=', 'not_paid'),
('move_type', '=', 'out_invoice')]))
total_due = fields.Monetary(compute='_compute_for_followup', store=False,
readonly=True)
next_reminder_date = fields.Date(compute='_compute_for_followup',
store=False, readonly=True)
total_overdue = fields.Monetary(compute='_compute_for_followup',
store=False, readonly=True)
followup_status = fields.Selection(
[('in_need_of_action', 'In need of action'),
('with_overdue_invoices', 'With overdue invoices'),
('no_action_needed', 'No action needed')],
string='Followup status',
)
warning_stage = fields.Float(string='Warning Amount',
help="A warning message will appear once the "
"selected customer is crossed warning "
"amount. Set its value to 0.00 to"
" disable this feature")
blocking_stage = fields.Float(string='Blocking Amount',
help="Cannot make sales once the selected "
"customer is crossed blocking amount."
"Set its value to 0.00 to disable "
"this feature")
due_amount = fields.Float(string="Total Sale",
compute="compute_due_amount")
active_limit = fields.Boolean("Active Credit Limit", default=False)
enable_credit_limit = fields.Boolean(string="Credit Limit Enabled",
compute="_compute_enable_credit_limit")
def _compute_for_followup(self):
"""
Compute the fields 'total_due', 'total_overdue' , 'next_reminder_date' and 'followup_status'
"""
for record in self:
total_due = 0
total_overdue = 0
today = fields.Date.today()
for am in record.invoice_list:
if am.company_id == self.env.company:
amount = am.amount_residual
total_due += amount
is_overdue = today > am.invoice_date_due if am.invoice_date_due else today > am.date
if is_overdue:
total_overdue += amount or 0
min_date = record.get_min_date()
action = record.action_after()
if min_date:
date_reminder = min_date + timedelta(days=action)
if date_reminder:
record.next_reminder_date = date_reminder
else:
date_reminder = today
record.next_reminder_date = date_reminder
if total_overdue > 0 and date_reminder > today:
followup_status = "with_overdue_invoices"
elif total_due > 0 and date_reminder <= today:
followup_status = "in_need_of_action"
else:
followup_status = "no_action_needed"
record.total_due = total_due
record.total_overdue = total_overdue
record.followup_status = followup_status
def get_min_date(self):
"""Get the minimum invoice due date from the partner's invoice list."""
today = date.today()
for this in self:
if this.invoice_list:
min_list = this.invoice_list.mapped('invoice_date_due')
while False in min_list:
min_list.remove(False)
return min(min_list)
else:
return today
def get_delay(self):
"""Retrieve the delay information for follow-up lines associated with the company."""
delay = """SELECT fl.id, fl.delay
FROM followup_line fl
JOIN account_followup af ON fl.followup_id = af.id
WHERE af.company_id = %s
ORDER BY fl.delay;
"""
self._cr.execute(delay, [self.env.company.id])
record = self._cr.dictfetchall()
return record
def action_after(self):
"""Retrieve the delay information for follow-up lines associated with the company and return the delay value if found."""
lines = self.env['followup.line'].search([(
'followup_id.company_id', '=', self.env.company.id)])
if lines:
record = self.get_delay()
for i in record:
return i['delay']
def compute_due_amount(self):
"""Compute function to compute the due amount with the
credit and debit amount"""
for rec in self:
if not rec.id:
continue
rec.due_amount = rec.credit - rec.debit
def _compute_enable_credit_limit(self):
""" Check credit limit is enabled in account settings """
params = self.env['ir.config_parameter'].sudo()
customer_credit_limit = params.get_param('customer_credit_limit',
default=False)
for rec in self:
rec.enable_credit_limit = True if customer_credit_limit else False
@api.constrains('warning_stage', 'blocking_stage')
def constrains_warning_stage(self):
"""Constrains functionality used to indicate or raise an
UserError"""
if self.active_limit and self.enable_credit_limit:
if self.warning_stage >= self.blocking_stage:
if self.blocking_stage > 0:
raise UserError(_(
"Warning amount should be less than Blocking amount"))
# customer statement
customer_report_ids = fields.Many2many(
'account.move',
compute='_compute_customer_report_ids',
help='Partner Invoices related to Customer')
vendor_statement_ids = fields.Many2many(
'account.move',
compute='_compute_vendor_statement_ids',
help='Partner Bills related to Vendor')
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id.id,
help="currency related to Customer or Vendor")
def _compute_customer_report_ids(self):
""" For computing 'invoices' of partner """
for rec in self:
inv_ids = self.env['account.move'].search(
[('partner_id', '=', rec.id),
('move_type', '=', 'out_invoice'),
('payment_state', '!=', 'paid'),
('state', '=', 'posted')])
rec.customer_report_ids = inv_ids
def _compute_vendor_statement_ids(self):
""" For computing 'bills' of partner """
for rec in self:
bills = self.env['account.move'].search(
[('partner_id', '=', rec.id),
('move_type', '=', 'in_invoice'),
('payment_state', '!=', 'paid'),
('state', '=', 'posted')])
rec.vendor_statement_ids = bills
def main_query(self):
""" Return select query """
query = """SELECT name , invoice_date, invoice_date_due,
amount_total_signed AS sub_total,
amount_residual_signed AS amount_due ,
amount_residual AS balance
FROM account_move WHERE payment_state != 'paid'
AND state ='posted' AND partner_id= '%s'
AND company_id = '%s' """ % (self.id, self.env.company.id)
return query
def amount_query(self):
""" Return query for calculating total amount """
amount_query = """ SELECT SUM(amount_total_signed) AS total,
SUM(amount_residual) AS balance
FROM account_move WHERE payment_state != 'paid'
AND state ='posted' AND partner_id= '%s'
AND company_id = '%s' """ % (self.id, self.env.company.id)
return amount_query
def action_share_pdf(self):
""" Action for sharing customer pdf report """
if self.customer_report_ids:
main_query = self.main_query()
main_query += """ AND move_type IN ('out_invoice')"""
amount = self.amount_query()
amount += """ AND move_type IN ('out_invoice')"""
self.env.cr.execute(main_query)
main = self.env.cr.dictfetchall()
self.env.cr.execute(amount)
amount = self.env.cr.dictfetchall()
data = {
'customer': self.display_name,
'street': self.street,
'street2': self.street2,
'city': self.city,
'state': self.state_id.name,
'zip': self.zip,
'my_data': main,
'total': amount[0]['total'],
'balance': amount[0]['balance'],
'currency': self.currency_id.symbol,
}
report = self.env['ir.actions.report'].sudo()._render_qweb_pdf(
'base_accounting_kit.res_partner_action', self, data=data)
data_record = base64.b64encode(report[0])
ir_values = {
'name': 'Statement Report',
'type': 'binary',
'datas': data_record,
'mimetype': 'application/pdf',
'res_model': 'res.partner'
}
attachment = self.env['ir.attachment'].sudo().create(ir_values)
email_values = {
'email_to': self.email,
'subject': 'Payment Statement Report',
'body_html': '<p>Dear <strong> Mr/Miss. ' + self.name +
'</strong> </p> <p> We have attached your '
'payment statement. Please check </p> '
'<p>Best regards, </p> <p> ' + self.env.user.name,
'attachment_ids': [attachment.id],
}
mail = self.env['mail.mail'].sudo().create(email_values)
mail.send()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': 'Email Sent Successfully',
'type': 'success',
'sticky': False
}
}
else:
raise ValidationError('There is no statement to send')
def action_print_pdf(self):
""" Action for printing pdf report """
if self.customer_report_ids:
main_query = self.main_query()
main_query += """ AND move_type IN ('out_invoice')"""
amount = self.amount_query()
amount += """ AND move_type IN ('out_invoice')"""
self.env.cr.execute(main_query)
main = self.env.cr.dictfetchall()
self.env.cr.execute(amount)
amount = self.env.cr.dictfetchall()
data = {
'customer': self.display_name,
'street': self.street,
'street2': self.street2,
'city': self.city,
'state': self.state_id.name,
'zip': self.zip,
'my_data': main,
'total': amount[0]['total'],
'balance': amount[0]['balance'],
'currency': self.currency_id.symbol,
}
return self.env.ref('base_accounting_kit.res_partner_action'
).report_action(self, data=data)
else:
raise ValidationError('There is no statement to print')
def action_print_xlsx(self):
""" Action for printing xlsx report of customers """
if self.customer_report_ids:
main_query = self.main_query()
main_query += """ AND move_type IN ('out_invoice')"""
amount = self.amount_query()
amount += """ AND move_type IN ('out_invoice')"""
self.env.cr.execute(main_query)
main = self.env.cr.dictfetchall()
self.env.cr.execute(amount)
amount = self.env.cr.dictfetchall()
data = {
'customer': self.display_name,
'street': self.street,
'street2': self.street2,
'city': self.city,
'state': self.state_id.name,
'zip': self.zip,
'my_data': main,
'total': amount[0]['total'],
'balance': amount[0]['balance'],
'currency': self.currency_id.symbol,
}
return {
'type': 'ir.actions.report',
'data': {
'model': 'res.partner',
'options': json.dumps(data,
default=json_default),
'output_format': 'xlsx',
'report_name': 'Payment Statement Report'
},
'report_type': 'xlsx',
}
else:
raise ValidationError('There is no statement to print')
def get_xlsx_report(self, data, response):
""" Get xlsx report data """
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
sheet = workbook.add_worksheet()
cell_format_with_color = workbook.add_format({
'font_size': '14px', 'bold': True,
'bg_color': 'yellow', 'border': 1})
cell_format = workbook.add_format({'font_size': '14px', 'bold': True})
txt = workbook.add_format({'font_size': '13px'})
txt_border = workbook.add_format({'font_size': '13px', 'border': 1})
head = workbook.add_format({'align': 'center', 'bold': True,
'font_size': '22px'})
sheet.merge_range('B2:Q4', 'Payment Statement Report', head)
if data['customer']:
sheet.merge_range('B7:D7', 'Customer/Supplier : ', cell_format)
sheet.merge_range('E7:H7', data['customer'], txt)
sheet.merge_range('B9:C9', 'Address : ', cell_format)
if data['street']:
sheet.merge_range('D9:F9', data['street'], txt)
if data['street2']:
sheet.merge_range('D10:F10', data['street2'], txt)
if data['city']:
sheet.merge_range('D11:F11', data['city'], txt)
if data['state']:
sheet.merge_range('D12:F12', data['state'], )
if data['zip']:
sheet.merge_range('D13:F13', data['zip'], txt)
sheet.merge_range('B15:C15', 'Date', cell_format_with_color)
sheet.merge_range('D15:G15', 'Invoice/Bill Number',
cell_format_with_color)
sheet.merge_range('H15:I15', 'Due Date', cell_format_with_color)
sheet.merge_range('J15:L15', 'Invoices/Debit', cell_format_with_color)
sheet.merge_range('M15:O15', 'Amount Due', cell_format_with_color)
sheet.merge_range('P15:R15', 'Balance Due', cell_format_with_color)
row = 15
column = 0
for record in data['my_data']:
sub_total = data['currency'] + str(record['sub_total'])
amount_due = data['currency'] + str(record['amount_due'])
balance = data['currency'] + str(record['balance'])
total = data['currency'] + str(data['total'])
remain_balance = data['currency'] + str(data['balance'])
sheet.merge_range(row, column + 1, row, column + 2,
record['invoice_date'], txt_border)
sheet.merge_range(row, column + 3, row, column + 6,
record['name'], txt_border)
sheet.merge_range(row, column + 7, row, column + 8,
record['invoice_date_due'], txt_border)
sheet.merge_range(row, column + 9, row, column + 11,
sub_total, txt_border)
sheet.merge_range(row, column + 12, row, column + 14,
amount_due, txt_border)
sheet.merge_range(row, column + 15, row, column + 17,
balance, txt_border)
row = row + 1
sheet.write(row + 2, column + 1, 'Total Amount: ', cell_format)
sheet.merge_range(row + 2, column + 3, row + 2, column + 4,
total, txt)
sheet.write(row + 4, column + 1, 'Balance Due: ', cell_format)
sheet.merge_range(row + 4, column + 3, row + 4, column + 4,
remain_balance, txt)
workbook.close()
output.seek(0)
response.stream.write(output.read())
output.close()
def action_share_xlsx(self):
""" Action for sharing xlsx report via email """
if self.customer_report_ids:
main_query = self.main_query()
main_query += """ AND move_type IN ('out_invoice')"""
amount = self.amount_query()
amount += """ AND move_type IN ('out_invoice')"""
self.env.cr.execute(main_query)
main = self.env.cr.dictfetchall()
self.env.cr.execute(amount)
amount = self.env.cr.dictfetchall()
data = {
'customer': self.display_name,
'street': self.street,
'street2': self.street2,
'city': self.city,
'state': self.state_id.name,
'zip': self.zip,
'my_data': main,
'total': amount[0]['total'],
'balance': amount[0]['balance'],
'currency': self.currency_id.symbol,
}
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
sheet = workbook.add_worksheet()
cell_format = workbook.add_format({
'font_size': '14px', 'bold': True})
txt = workbook.add_format({'font_size': '13px'})
head = workbook.add_format(
{'align': 'center', 'bold': True, 'font_size': '22px'})
sheet.merge_range('B2:P4', 'Payment Statement Report', head)
date_style = workbook.add_format(
{'text_wrap': True, 'align': 'center',
'num_format': 'yyyy-mm-dd'})
if data['customer']:
sheet.write('B7:C7', 'Customer : ', cell_format)
sheet.merge_range('D7:G7', data['customer'], txt)
sheet.write('B9:C7', 'Address : ', cell_format)
if data['street']:
sheet.merge_range('D9:F9', data['street'], txt)
if data['street2']:
sheet.merge_range('D10:F10', data['street2'], txt)
if data['city']:
sheet.merge_range('D11:F11', data['city'], txt)
if data['state']:
sheet.merge_range('D12:F12', data['state'], txt)
if data['zip']:
sheet.merge_range('D13:F13', data['zip'], txt)
sheet.write('B15', 'Date', cell_format)
sheet.write('D15', 'Invoice/Bill Number', cell_format)
sheet.write('H15', 'Due Date', cell_format)
sheet.write('J15', 'Invoices/Debit', cell_format)
sheet.write('M15', 'Amount Due', cell_format)
sheet.write('P15', 'Balance Due', cell_format)
row = 16
column = 0
for record in data['my_data']:
sub_total = data['currency'] + str(record['sub_total'])
amount_due = data['currency'] + str(record['amount_due'])
balance = data['currency'] + str(record['balance'])
total = data['currency'] + str(data['total'])
remain_balance = data['currency'] + str(data['balance'])
sheet.merge_range(row, column + 1, row, column + 2,
record['invoice_date'], date_style)
sheet.merge_range(row, column + 3, row, column + 5,
record['name'], txt)
sheet.merge_range(row, column + 7, row, column + 8,
record['invoice_date_due'], date_style)
sheet.merge_range(row, column + 9, row, column + 10,
sub_total, txt)
sheet.merge_range(row, column + 12, row, column + 13,
amount_due, txt)
sheet.merge_range(row, column + 15, row, column + 16,
balance, txt)
row = row + 1
sheet.write(row + 2, column + 1, 'Total Amount : ', cell_format)
sheet.merge_range(row + 2, column + 4, row + 2, column + 5,
total, txt)
sheet.write(row + 4, column + 1, 'Balance Due : ', cell_format)
sheet.merge_range(row + 4, column + 4, row + 4, column + 5,
remain_balance, txt)
workbook.close()
output.seek(0)
xlsx = base64.b64encode(output.read())
output.close()
ir_values = {
'name': "Statement Report.xlsx",
'type': 'binary',
'datas': xlsx,
'store_fname': xlsx,
}
attachment = self.env['ir.attachment'].sudo().create(ir_values)
email_values = {
'email_to': self.email,
'subject': 'Payment Statement Report',
'body_html': '<p>Dear <strong> Mr/Miss. ' + self.name +
'</strong> </p> <p> We have attached your'
' payment statement. Please check </p> '
'<p>Best regards, </p> <p> ' + self.env.user.name,
'attachment_ids': [attachment.id],
}
mail = self.env['mail.mail'].sudo().create(email_values)
mail.send()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': 'Email Sent Successfully',
'type': 'success',
'sticky': False
}
}
else:
raise ValidationError('There is no statement to send')
+66
View File
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.tools.translate import _
class SaleOrder(models.Model):
"""The Class inherits the sale.order model for adding the new
fields and functions"""
_inherit = 'sale.order'
has_due = fields.Boolean(string='Has due')
is_warning = fields.Boolean(string='Is warning')
due_amount = fields.Float(string='Due Amount',
related='partner_id.due_amount')
def _action_confirm(self):
"""To check the selected customers due amount is exceed than
blocking stage"""
if self.partner_id.active_limit \
and self.partner_id.enable_credit_limit:
if self.due_amount >= self.partner_id.blocking_stage:
if self.partner_id.blocking_stage != 0:
raise UserError(_(
"%s is in Blocking Stage and "
"has a due amount of %s %s to pay") % (
self.partner_id.name, self.due_amount,
self.currency_id.symbol))
return super(SaleOrder, self)._action_confirm()
@api.onchange('partner_id')
def check_due(self):
"""To show the due amount and warning stage"""
if self.partner_id and self.partner_id.due_amount > 0 \
and self.partner_id.active_limit \
and self.partner_id.enable_credit_limit:
self.has_due = True
else:
self.has_due = False
if self.partner_id and self.partner_id.active_limit\
and self.partner_id.enable_credit_limit:
if self.due_amount >= self.partner_id.warning_stage:
if self.partner_id.warning_stage != 0:
self.is_warning = True
else:
self.is_warning = False