771 lines
38 KiB
Python
771 lines
38 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
|
||
|
from collections import defaultdict
|
||
|
from datetime import timedelta, datetime, date
|
||
|
import calendar
|
||
|
|
||
|
from odoo import fields, models, api, _, Command
|
||
|
from odoo.exceptions import ValidationError, UserError, RedirectWarning
|
||
|
from odoo.tools import date_utils
|
||
|
from odoo.tools.mail import is_html_empty
|
||
|
from odoo.tools.misc import format_date
|
||
|
from odoo.tools.float_utils import float_round, float_is_zero
|
||
|
from odoo.addons.account.models.account_move import MAX_HASH_VERSION
|
||
|
|
||
|
|
||
|
MONTH_SELECTION = [
|
||
|
('1', 'January'),
|
||
|
('2', 'February'),
|
||
|
('3', 'March'),
|
||
|
('4', 'April'),
|
||
|
('5', 'May'),
|
||
|
('6', 'June'),
|
||
|
('7', 'July'),
|
||
|
('8', 'August'),
|
||
|
('9', 'September'),
|
||
|
('10', 'October'),
|
||
|
('11', 'November'),
|
||
|
('12', 'December'),
|
||
|
]
|
||
|
|
||
|
# List of countries where Peppol should be used by default.
|
||
|
PEPPOL_DEFAULT_COUNTRIES = [
|
||
|
'AT', 'BE', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI',
|
||
|
'FR', 'GR', 'IE', 'IS', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL',
|
||
|
'NO', 'PL', 'PT', 'RO', 'SE', 'SI',
|
||
|
]
|
||
|
|
||
|
# List of countries where Peppol is accessible.
|
||
|
PEPPOL_LIST = PEPPOL_DEFAULT_COUNTRIES + [
|
||
|
'AD', 'AL', 'BA', 'BG', 'GB', 'HR', 'HU', 'LI', 'MC', 'ME',
|
||
|
'MK', 'RS', 'SK', 'SM', 'TR', 'VA',
|
||
|
]
|
||
|
|
||
|
|
||
|
class ResCompany(models.Model):
|
||
|
_name = "res.company"
|
||
|
_inherit = ["res.company", "mail.thread"]
|
||
|
|
||
|
#TODO check all the options/fields are in the views (settings + company form view)
|
||
|
fiscalyear_last_day = fields.Integer(default=31, required=True)
|
||
|
fiscalyear_last_month = fields.Selection(MONTH_SELECTION, default='12', required=True)
|
||
|
period_lock_date = fields.Date(
|
||
|
string="Journals Entries Lock Date",
|
||
|
tracking=True,
|
||
|
help="Only users with the 'Adviser' role can edit accounts prior to and inclusive of this"
|
||
|
" date. Use it for period locking inside an open fiscal year, for example.")
|
||
|
fiscalyear_lock_date = fields.Date(
|
||
|
string="All Users Lock Date",
|
||
|
tracking=True,
|
||
|
help="No users, including Advisers, can edit accounts prior to and inclusive of this date."
|
||
|
" Use it for fiscal year locking for example.")
|
||
|
tax_lock_date = fields.Date(
|
||
|
string="Tax Return Lock Date",
|
||
|
tracking=True,
|
||
|
help="No users can edit journal entries related to a tax prior and inclusive of this date.")
|
||
|
max_tax_lock_date = fields.Date(compute='_compute_max_tax_lock_date', recursive=True) # TODO maybe store
|
||
|
transfer_account_id = fields.Many2one('account.account',
|
||
|
check_company=True,
|
||
|
domain="[('reconcile', '=', True), ('account_type', '=', 'asset_current'), ('deprecated', '=', False)]", string="Inter-Banks Transfer Account", help="Intermediary account used when moving money from a liqity account to another")
|
||
|
expects_chart_of_accounts = fields.Boolean(string='Expects a Chart of Accounts', default=True)
|
||
|
chart_template = fields.Selection(selection='_chart_template_selection')
|
||
|
bank_account_code_prefix = fields.Char(string='Prefix of the bank accounts')
|
||
|
cash_account_code_prefix = fields.Char(string='Prefix of the cash accounts')
|
||
|
default_cash_difference_income_account_id = fields.Many2one('account.account', string="Cash Difference Income", check_company=True)
|
||
|
default_cash_difference_expense_account_id = fields.Many2one('account.account', string="Cash Difference Expense", check_company=True)
|
||
|
account_journal_suspense_account_id = fields.Many2one('account.account', string='Journal Suspense Account', check_company=True)
|
||
|
account_journal_payment_debit_account_id = fields.Many2one('account.account', string='Journal Outstanding Receipts', check_company=True)
|
||
|
account_journal_payment_credit_account_id = fields.Many2one('account.account', string='Journal Outstanding Payments', check_company=True)
|
||
|
account_journal_early_pay_discount_gain_account_id = fields.Many2one(comodel_name='account.account', string='Cash Discount Write-Off Gain Account', check_company=True)
|
||
|
account_journal_early_pay_discount_loss_account_id = fields.Many2one(comodel_name='account.account', string='Cash Discount Write-Off Loss Account', check_company=True)
|
||
|
transfer_account_code_prefix = fields.Char(string='Prefix of the transfer accounts')
|
||
|
account_sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax", check_company=True)
|
||
|
account_purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax", check_company=True)
|
||
|
tax_calculation_rounding_method = fields.Selection([
|
||
|
('round_per_line', 'Round per Line'),
|
||
|
('round_globally', 'Round Globally'),
|
||
|
], default='round_per_line', string='Tax Calculation Rounding Method')
|
||
|
currency_exchange_journal_id = fields.Many2one('account.journal', string="Exchange Gain or Loss Journal", domain=[('type', '=', 'general')])
|
||
|
income_currency_exchange_account_id = fields.Many2one(
|
||
|
comodel_name='account.account',
|
||
|
string="Gain Exchange Rate Account",
|
||
|
check_company=True,
|
||
|
domain="[('deprecated', '=', False),\
|
||
|
('internal_group', '=', 'income')]")
|
||
|
expense_currency_exchange_account_id = fields.Many2one(
|
||
|
comodel_name='account.account',
|
||
|
string="Loss Exchange Rate Account",
|
||
|
check_company=True,
|
||
|
domain="[('deprecated', '=', False), \
|
||
|
('account_type', '=', 'expense')]")
|
||
|
anglo_saxon_accounting = fields.Boolean(string="Use anglo-saxon accounting")
|
||
|
bank_journal_ids = fields.One2many('account.journal', 'company_id', domain=[('type', '=', 'bank')], string='Bank Journals')
|
||
|
incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm',
|
||
|
help='International Commercial Terms are a series of predefined commercial terms used in international transactions.')
|
||
|
|
||
|
qr_code = fields.Boolean(string='Display QR-code on invoices')
|
||
|
|
||
|
invoice_is_email = fields.Boolean('Email by default', default=True)
|
||
|
invoice_is_download = fields.Boolean('Download by default', default=True)
|
||
|
display_invoice_amount_total_words = fields.Boolean(string='Total amount of invoice in letters')
|
||
|
account_use_credit_limit = fields.Boolean(
|
||
|
string='Sales Credit Limit', help='Enable the use of credit limit on partners.')
|
||
|
|
||
|
#Fields of the setup step for opening move
|
||
|
account_opening_move_id = fields.Many2one(string='Opening Journal Entry', comodel_name='account.move', help="The journal entry containing the initial balance of all this company's accounts.")
|
||
|
account_opening_journal_id = fields.Many2one(string='Opening Journal', comodel_name='account.journal', related='account_opening_move_id.journal_id', help="Journal where the opening entry of this company's accounting has been posted.", readonly=False)
|
||
|
account_opening_date = fields.Date(string='Opening Entry', default=lambda self: fields.Date.context_today(self).replace(month=1, day=1), required=True, help="That is the date of the opening entry.")
|
||
|
|
||
|
invoice_terms = fields.Html(string='Default Terms and Conditions', translate=True)
|
||
|
terms_type = fields.Selection([('plain', 'Add a Note'), ('html', 'Add a link to a Web Page')],
|
||
|
string='Terms & Conditions format', default='plain')
|
||
|
invoice_terms_html = fields.Html(string='Default Terms and Conditions as a Web page', translate=True,
|
||
|
sanitize_attributes=False,
|
||
|
compute='_compute_invoice_terms_html', store=True, readonly=False)
|
||
|
|
||
|
# Needed in the Point of Sale
|
||
|
account_default_pos_receivable_account_id = fields.Many2one('account.account', string="Default PoS Receivable Account", check_company=True)
|
||
|
|
||
|
# Accrual Accounting
|
||
|
expense_accrual_account_id = fields.Many2one('account.account',
|
||
|
help="Account used to move the period of an expense",
|
||
|
check_company=True,
|
||
|
domain="[('internal_group', '=', 'liability'), ('account_type', 'not in', ('asset_receivable', 'liability_payable'))]")
|
||
|
revenue_accrual_account_id = fields.Many2one('account.account',
|
||
|
help="Account used to move the period of a revenue",
|
||
|
check_company=True,
|
||
|
domain="[('internal_group', '=', 'asset'), ('account_type', 'not in', ('asset_receivable', 'liability_payable'))]")
|
||
|
automatic_entry_default_journal_id = fields.Many2one(
|
||
|
'account.journal',
|
||
|
domain="[('type', '=', 'general')]",
|
||
|
check_company=True,
|
||
|
help="Journal used by default for moving the period of an entry",
|
||
|
)
|
||
|
|
||
|
# Technical field to hide country specific fields in company form view
|
||
|
country_code = fields.Char(related='country_id.code', depends=['country_id'])
|
||
|
|
||
|
# Taxes
|
||
|
account_fiscal_country_id = fields.Many2one(
|
||
|
string="Fiscal Country",
|
||
|
comodel_name='res.country',
|
||
|
compute='compute_account_tax_fiscal_country',
|
||
|
store=True,
|
||
|
readonly=False,
|
||
|
help="The country to use the tax reports from for this company")
|
||
|
|
||
|
account_enabled_tax_country_ids = fields.Many2many(
|
||
|
string="l10n-used countries",
|
||
|
comodel_name='res.country',
|
||
|
compute='_compute_account_enabled_tax_country_ids',
|
||
|
help="Technical field containing the countries for which this company is using tax-related features"
|
||
|
"(hence the ones for which l10n modules need to show tax-related fields).")
|
||
|
|
||
|
# Cash basis taxes
|
||
|
tax_exigibility = fields.Boolean(string='Use Cash Basis')
|
||
|
tax_cash_basis_journal_id = fields.Many2one(
|
||
|
comodel_name='account.journal',
|
||
|
check_company=True,
|
||
|
string="Cash Basis Journal")
|
||
|
account_cash_basis_base_account_id = fields.Many2one(
|
||
|
comodel_name='account.account',
|
||
|
check_company=True,
|
||
|
domain=[('deprecated', '=', False)],
|
||
|
string="Base Tax Received Account",
|
||
|
help="Account that will be set on lines created in cash basis journal entry and used to keep track of the "
|
||
|
"tax base amount.")
|
||
|
|
||
|
# Storno Accounting
|
||
|
account_storno = fields.Boolean(string="Storno accounting", readonly=False)
|
||
|
|
||
|
# Multivat
|
||
|
fiscal_position_ids = fields.One2many(comodel_name="account.fiscal.position", inverse_name="company_id")
|
||
|
multi_vat_foreign_country_ids = fields.Many2many(
|
||
|
string="Foreign VAT countries",
|
||
|
help="Countries for which the company has a VAT number",
|
||
|
comodel_name='res.country',
|
||
|
compute='_compute_multi_vat_foreign_country',
|
||
|
)
|
||
|
|
||
|
# Fiduciary mode
|
||
|
quick_edit_mode = fields.Selection(
|
||
|
selection=[
|
||
|
('out_invoices', 'Customer Invoices'),
|
||
|
('in_invoices', 'Vendor Bills'),
|
||
|
('out_and_in_invoices', 'Customer Invoices and Vendor Bills')],
|
||
|
string="Quick encoding")
|
||
|
|
||
|
# Separate account for allocation of discounts
|
||
|
account_discount_income_allocation_id = fields.Many2one(comodel_name='account.account', string='Separate account for income discount')
|
||
|
account_discount_expense_allocation_id = fields.Many2one(comodel_name='account.account', string='Separate account for expense discount')
|
||
|
|
||
|
def _get_company_root_delegated_field_names(self):
|
||
|
return super()._get_company_root_delegated_field_names() + [
|
||
|
'fiscalyear_last_day',
|
||
|
'fiscalyear_last_month',
|
||
|
'account_storno',
|
||
|
'tax_exigibility',
|
||
|
]
|
||
|
|
||
|
@api.constrains('account_opening_move_id', 'fiscalyear_last_day', 'fiscalyear_last_month')
|
||
|
def _check_fiscalyear_last_day(self):
|
||
|
# if the user explicitly chooses the 29th of February we allow it:
|
||
|
# there is no "fiscalyear_last_year" so we do not know his intentions.
|
||
|
for rec in self:
|
||
|
if rec.fiscalyear_last_day == 29 and rec.fiscalyear_last_month == '2':
|
||
|
continue
|
||
|
|
||
|
if rec.account_opening_date:
|
||
|
year = rec.account_opening_date.year
|
||
|
else:
|
||
|
year = datetime.now().year
|
||
|
|
||
|
max_day = calendar.monthrange(year, int(rec.fiscalyear_last_month))[1]
|
||
|
if rec.fiscalyear_last_day > max_day:
|
||
|
raise ValidationError(_("Invalid fiscal year last day"))
|
||
|
|
||
|
@api.depends('fiscal_position_ids.foreign_vat')
|
||
|
def _compute_multi_vat_foreign_country(self):
|
||
|
company_to_foreign_vat_country = {
|
||
|
company.id: country_ids
|
||
|
for company, country_ids in self.env['account.fiscal.position']._read_group(
|
||
|
domain=[
|
||
|
*self.env['account.fiscal.position']._check_company_domain(self),
|
||
|
('foreign_vat', '!=', False),
|
||
|
],
|
||
|
groupby=['company_id'],
|
||
|
aggregates=['country_id:array_agg'],
|
||
|
)
|
||
|
}
|
||
|
for company in self:
|
||
|
company.multi_vat_foreign_country_ids = self.env['res.country'].browse(company_to_foreign_vat_country.get(company.id))
|
||
|
|
||
|
@api.depends('country_id')
|
||
|
def compute_account_tax_fiscal_country(self):
|
||
|
for record in self:
|
||
|
if not record.account_fiscal_country_id:
|
||
|
record.account_fiscal_country_id = record.country_id
|
||
|
|
||
|
@api.depends('account_fiscal_country_id')
|
||
|
def _compute_account_enabled_tax_country_ids(self):
|
||
|
for record in self:
|
||
|
if record not in self.env.user.company_ids:
|
||
|
# can have access to the company form without having access to its content (see base.res_company_rule_erp_manager)
|
||
|
record.account_enabled_tax_country_ids = False
|
||
|
continue
|
||
|
foreign_vat_fpos = self.env['account.fiscal.position'].search([
|
||
|
*self.env['account.fiscal.position']._check_company_domain(record),
|
||
|
('foreign_vat', '!=', False)
|
||
|
])
|
||
|
record.account_enabled_tax_country_ids = foreign_vat_fpos.country_id + record.account_fiscal_country_id
|
||
|
|
||
|
@api.depends('terms_type')
|
||
|
def _compute_invoice_terms_html(self):
|
||
|
for company in self.filtered(lambda company: is_html_empty(company.invoice_terms_html) and company.terms_type == 'html'):
|
||
|
html = self.env['ir.qweb']._render('account.account_default_terms_and_conditions',
|
||
|
{'company_name': company.name, 'company_country': company.country_id.name},
|
||
|
raise_if_not_found=False)
|
||
|
if html:
|
||
|
company.invoice_terms_html = html
|
||
|
|
||
|
@api.depends('tax_lock_date', 'parent_id.max_tax_lock_date')
|
||
|
def _compute_max_tax_lock_date(self):
|
||
|
for company in self:
|
||
|
company.max_tax_lock_date = max(company.tax_lock_date or date.min, company.parent_id.sudo().max_tax_lock_date or date.min)
|
||
|
|
||
|
@api.model_create_multi
|
||
|
def create(self, vals_list):
|
||
|
companies = super().create(vals_list)
|
||
|
for company in companies:
|
||
|
if root_template := company.parent_ids[0].chart_template:
|
||
|
def try_loading(company=company):
|
||
|
self.env['account.chart.template']._load(
|
||
|
root_template,
|
||
|
company,
|
||
|
install_demo=False,
|
||
|
)
|
||
|
self.env.cr.precommit.add(try_loading)
|
||
|
return companies
|
||
|
|
||
|
def get_new_account_code(self, current_code, old_prefix, new_prefix):
|
||
|
digits = len(current_code)
|
||
|
return new_prefix + current_code.replace(old_prefix, '', 1).lstrip('0').rjust(digits-len(new_prefix), '0')
|
||
|
|
||
|
def reflect_code_prefix_change(self, old_code, new_code):
|
||
|
if not old_code or new_code == old_code:
|
||
|
return
|
||
|
accounts = self.env['account.account'].search([
|
||
|
*self.env['account.account']._check_company_domain(self),
|
||
|
('code', '=like', old_code + '%'),
|
||
|
('account_type', 'in', ('asset_cash', 'liability_credit_card')),
|
||
|
], order='code asc')
|
||
|
for account in accounts:
|
||
|
account.write({'code': self.get_new_account_code(account.code, old_code, new_code)})
|
||
|
|
||
|
def _get_fiscalyear_lock_statement_lines_redirect_action(self, unreconciled_statement_lines):
|
||
|
""" Get the action redirecting to the statement lines that are not already reconciled when setting a fiscal
|
||
|
year lock date.
|
||
|
|
||
|
:param unreconciled_statement_lines: The statement lines.
|
||
|
:return: A dictionary representing a window action.
|
||
|
"""
|
||
|
|
||
|
action = {
|
||
|
'name': _("Unreconciled Transactions"),
|
||
|
'type': 'ir.actions.act_window',
|
||
|
'res_model': 'account.bank.statement.line',
|
||
|
'context': {'create': False},
|
||
|
}
|
||
|
if len(unreconciled_statement_lines) == 1:
|
||
|
action.update({
|
||
|
'view_mode': 'form',
|
||
|
'res_id': unreconciled_statement_lines.id,
|
||
|
})
|
||
|
else:
|
||
|
action.update({
|
||
|
'view_mode': 'list,form',
|
||
|
'domain': [('id', 'in', unreconciled_statement_lines.ids)],
|
||
|
})
|
||
|
return action
|
||
|
|
||
|
def _validate_fiscalyear_lock(self, values):
|
||
|
if values.get('fiscalyear_lock_date'):
|
||
|
|
||
|
draft_entries = self.env['account.move'].search([
|
||
|
('company_id', 'child_of', self.ids),
|
||
|
('state', '=', 'draft'),
|
||
|
('date', '<=', values['fiscalyear_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': 'tree',
|
||
|
'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', 'child_of', self.ids),
|
||
|
('is_reconciled', '=', False),
|
||
|
('date', '<=', values['fiscalyear_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 = self._get_fiscalyear_lock_statement_lines_redirect_action(unreconciled_statement_lines)
|
||
|
raise RedirectWarning(error_msg, action_error, _('Show Unreconciled Bank Statement Line'))
|
||
|
|
||
|
def _get_user_fiscal_lock_date(self):
|
||
|
"""Get the fiscal lock date for this company depending on the user"""
|
||
|
lock_date = max(self.period_lock_date or date.min, self.fiscalyear_lock_date or date.min)
|
||
|
if self.user_has_groups('account.group_account_manager'):
|
||
|
lock_date = self.fiscalyear_lock_date or date.min
|
||
|
if self.parent_id:
|
||
|
# We need to use sudo, since we might not have access to a parent company.
|
||
|
lock_date = max(lock_date, self.sudo().parent_id._get_user_fiscal_lock_date())
|
||
|
return lock_date
|
||
|
|
||
|
def _get_violated_lock_dates(self, accounting_date, has_tax):
|
||
|
"""Get all the lock dates affecting the current accounting_date.
|
||
|
:param accoutiaccounting_dateng_date: The accounting date
|
||
|
:param has_tax: If any taxes are involved in the lines of the invoice
|
||
|
:return: a list of tuples containing the lock dates ordered chronologically.
|
||
|
"""
|
||
|
self.ensure_one()
|
||
|
locks = []
|
||
|
user_lock_date = self._get_user_fiscal_lock_date()
|
||
|
if accounting_date and user_lock_date and accounting_date <= user_lock_date:
|
||
|
locks.append((user_lock_date, _('user')))
|
||
|
tax_lock_date = self.max_tax_lock_date
|
||
|
if accounting_date and tax_lock_date and has_tax and accounting_date <= tax_lock_date:
|
||
|
locks.append((tax_lock_date, _('tax')))
|
||
|
locks.sort()
|
||
|
return locks
|
||
|
|
||
|
def write(self, values):
|
||
|
#restrict the closing of FY if there are still unposted entries
|
||
|
self._validate_fiscalyear_lock(values)
|
||
|
|
||
|
# Reflect the change on accounts
|
||
|
for company in self:
|
||
|
if values.get('bank_account_code_prefix'):
|
||
|
new_bank_code = values.get('bank_account_code_prefix') or company.bank_account_code_prefix
|
||
|
company.reflect_code_prefix_change(company.bank_account_code_prefix, new_bank_code)
|
||
|
|
||
|
if values.get('cash_account_code_prefix'):
|
||
|
new_cash_code = values.get('cash_account_code_prefix') or company.cash_account_code_prefix
|
||
|
company.reflect_code_prefix_change(company.cash_account_code_prefix, new_cash_code)
|
||
|
|
||
|
#forbid the change of currency_id if there are already some accounting entries existing
|
||
|
if 'currency_id' in values and values['currency_id'] != company.currency_id.id:
|
||
|
if company.root_id._existing_accounting():
|
||
|
raise UserError(_('You cannot change the currency of the company since some journal items already exist'))
|
||
|
|
||
|
return super(ResCompany, self).write(values)
|
||
|
|
||
|
@api.model
|
||
|
def setting_init_bank_account_action(self):
|
||
|
""" Called by the 'Bank Accounts' button of the setup bar or from the Financial configuration menu."""
|
||
|
view_id = self.env.ref('account.setup_bank_account_wizard').id
|
||
|
return {
|
||
|
'type': 'ir.actions.act_window',
|
||
|
'name': _('Create a Bank Account'),
|
||
|
'res_model': 'account.setup.bank.manual.config',
|
||
|
'target': 'new',
|
||
|
'view_mode': 'form',
|
||
|
'views': [[view_id, 'form']],
|
||
|
}
|
||
|
|
||
|
@api.model
|
||
|
def _get_default_opening_move_values(self):
|
||
|
""" Get the default values to create the opening move.
|
||
|
|
||
|
:return: A dictionary to be passed to account.move.create.
|
||
|
"""
|
||
|
self.ensure_one()
|
||
|
default_journal = self.env['account.journal'].search(
|
||
|
domain=[
|
||
|
*self.env['account.journal']._check_company_domain(self),
|
||
|
('type', '=', 'general'),
|
||
|
],
|
||
|
limit=1,
|
||
|
)
|
||
|
|
||
|
if not default_journal:
|
||
|
raise UserError(_("Please install a chart of accounts or create a miscellaneous journal before proceeding."))
|
||
|
|
||
|
return {
|
||
|
'ref': _('Opening Journal Entry'),
|
||
|
'company_id': self.id,
|
||
|
'journal_id': default_journal.id,
|
||
|
'date': self.account_opening_date - timedelta(days=1),
|
||
|
}
|
||
|
|
||
|
def create_op_move_if_non_existant(self):
|
||
|
""" Creates an empty opening move in 'draft' state for the current company
|
||
|
if there wasn't already one defined. For this, the function needs at least
|
||
|
one journal of type 'general' to exist (required by account.move).
|
||
|
"""
|
||
|
# TO BE REMOVED IN MASTER
|
||
|
self.ensure_one()
|
||
|
if not self.account_opening_move_id:
|
||
|
self.account_opening_move_id = self.env['account.move'].create(self._get_default_opening_move_values())
|
||
|
|
||
|
def opening_move_posted(self):
|
||
|
""" Returns true if this company has an opening account move and this move is posted."""
|
||
|
return bool(self.account_opening_move_id) and self.account_opening_move_id.state == 'posted'
|
||
|
|
||
|
def get_unaffected_earnings_account(self):
|
||
|
""" Returns the unaffected earnings account for this company, creating one
|
||
|
if none has yet been defined.
|
||
|
"""
|
||
|
unaffected_earnings_type = "equity_unaffected"
|
||
|
account = self.env['account.account'].search([
|
||
|
*self.env['account.account']._check_company_domain(self),
|
||
|
('account_type', '=', unaffected_earnings_type),
|
||
|
])
|
||
|
if account:
|
||
|
return account[0]
|
||
|
# Do not assume '999999' doesn't exist since the user might have created such an account
|
||
|
# manually.
|
||
|
code = 999999
|
||
|
while self.env['account.account'].search([
|
||
|
*self.env['account.account']._check_company_domain(self),
|
||
|
('code', '=', str(code)),
|
||
|
]):
|
||
|
code -= 1
|
||
|
return self.env['account.account']._load_records([
|
||
|
{
|
||
|
'xml_id': f"account.{str(self.id)}_unaffected_earnings_account",
|
||
|
'values': {
|
||
|
'code': str(code),
|
||
|
'name': _('Undistributed Profits/Losses'),
|
||
|
'account_type': unaffected_earnings_type,
|
||
|
'company_id': self.id,
|
||
|
},
|
||
|
'noupdate': True,
|
||
|
}
|
||
|
])
|
||
|
|
||
|
def get_opening_move_differences(self, opening_move_lines):
|
||
|
# TO BE REMOVED IN MASTER
|
||
|
currency = self.currency_id
|
||
|
balancing_move_line = opening_move_lines.filtered(lambda x: x.account_id == self.get_unaffected_earnings_account())
|
||
|
|
||
|
debits_sum = credits_sum = 0.0
|
||
|
for line in opening_move_lines:
|
||
|
if line != balancing_move_line:
|
||
|
#skip the autobalancing move line
|
||
|
debits_sum += line.debit
|
||
|
credits_sum += line.credit
|
||
|
|
||
|
difference = abs(debits_sum - credits_sum)
|
||
|
debit_diff = (debits_sum > credits_sum) and float_round(difference, precision_rounding=currency.rounding) or 0.0
|
||
|
credit_diff = (debits_sum < credits_sum) and float_round(difference, precision_rounding=currency.rounding) or 0.0
|
||
|
return debit_diff, credit_diff
|
||
|
|
||
|
def _auto_balance_opening_move(self):
|
||
|
""" Checks the opening_move of this company. If it has not been posted yet
|
||
|
and is unbalanced, balances it with a automatic account.move.line in the
|
||
|
current year earnings account.
|
||
|
"""
|
||
|
# TO BE REMOVED IN MASTER
|
||
|
if self.account_opening_move_id and self.account_opening_move_id.state == 'draft':
|
||
|
balancing_account = self.get_unaffected_earnings_account()
|
||
|
currency = self.currency_id
|
||
|
|
||
|
balancing_move_line = self.account_opening_move_id.line_ids.filtered(lambda x: x.account_id == balancing_account)
|
||
|
# There could be multiple lines if we imported the balance from unaffected earnings account too
|
||
|
if len(balancing_move_line) > 1:
|
||
|
self.account_opening_move_id.line_ids -= balancing_move_line[1:]
|
||
|
balancing_move_line = balancing_move_line[0]
|
||
|
|
||
|
debit_diff, credit_diff = self.get_opening_move_differences(self.account_opening_move_id.line_ids)
|
||
|
|
||
|
if float_is_zero(debit_diff + credit_diff, precision_rounding=currency.rounding):
|
||
|
if balancing_move_line:
|
||
|
# zero difference and existing line : delete the line
|
||
|
self.account_opening_move_id.line_ids -= balancing_move_line
|
||
|
else:
|
||
|
if balancing_move_line:
|
||
|
# Non-zero difference and existing line : edit the line
|
||
|
balancing_move_line.write({'debit': credit_diff, 'credit': debit_diff})
|
||
|
else:
|
||
|
# Non-zero difference and no existing line : create a new line
|
||
|
self.env['account.move.line'].create({
|
||
|
'name': _('Automatic Balancing Line'),
|
||
|
'move_id': self.account_opening_move_id.id,
|
||
|
'account_id': balancing_account.id,
|
||
|
'debit': credit_diff,
|
||
|
'credit': debit_diff,
|
||
|
})
|
||
|
|
||
|
def _update_opening_move(self, to_update):
|
||
|
""" Create or update the opening move for the accounts passed as parameter.
|
||
|
|
||
|
:param to_update: A dictionary mapping each account with a tuple (debit, credit).
|
||
|
A separated opening line is created for both fields. A None value on debit/credit means the corresponding
|
||
|
line will not be updated.
|
||
|
"""
|
||
|
self.ensure_one()
|
||
|
|
||
|
# Don't allow to modify the opening move if not in draft.
|
||
|
opening_move = self.account_opening_move_id
|
||
|
if opening_move and opening_move.state != 'draft':
|
||
|
raise UserError(_(
|
||
|
'You cannot import the "openning_balance" if the opening move (%s) is already posted. \
|
||
|
If you are absolutely sure you want to modify the opening balance of your accounts, reset the move to draft.',
|
||
|
self.account_opening_move_id.name,
|
||
|
))
|
||
|
|
||
|
def del_lines(lines):
|
||
|
nonlocal open_balance
|
||
|
for line in lines:
|
||
|
open_balance -= line.balance
|
||
|
yield Command.delete(line.id)
|
||
|
|
||
|
def update_vals(account, side, balance, balancing=False):
|
||
|
nonlocal open_balance
|
||
|
corresponding_lines = corresponding_lines_per_account[(account, side)]
|
||
|
currency = account.currency_id or self.currency_id
|
||
|
amount_currency = balance if balancing else self.currency_id._convert(balance, currency, date=conversion_date)
|
||
|
open_balance += balance
|
||
|
if self.currency_id.is_zero(balance):
|
||
|
yield from del_lines(corresponding_lines)
|
||
|
elif corresponding_lines:
|
||
|
line_to_update = corresponding_lines[0]
|
||
|
open_balance -= line_to_update.balance
|
||
|
yield Command.update(line_to_update.id, {
|
||
|
'balance': balance,
|
||
|
'amount_currency': amount_currency,
|
||
|
})
|
||
|
yield from del_lines(corresponding_lines[1:])
|
||
|
else:
|
||
|
yield Command.create({
|
||
|
'name':_("Automatic Balancing Line") if balancing else _("Opening balance"),
|
||
|
'account_id': account.id,
|
||
|
'balance': balance,
|
||
|
'amount_currency': amount_currency,
|
||
|
'currency_id': currency.id,
|
||
|
})
|
||
|
|
||
|
# Decode the existing opening move.
|
||
|
corresponding_lines_per_account = defaultdict(lambda: self.env['account.move.line'])
|
||
|
corresponding_lines_per_account.update(opening_move.line_ids.grouped(lambda line: (
|
||
|
line.account_id,
|
||
|
'debit' if line.balance > 0.0 or line.amount_currency > 0.0 else 'credit',
|
||
|
)))
|
||
|
|
||
|
# Update the opening move's lines.
|
||
|
balancing_account = self.get_unaffected_earnings_account()
|
||
|
open_balance = (
|
||
|
sum(corresponding_lines_per_account[(balancing_account, 'credit')].mapped('credit'))
|
||
|
-sum(corresponding_lines_per_account[(balancing_account, 'debit')].mapped('debit'))
|
||
|
)
|
||
|
commands = []
|
||
|
move_values = {'line_ids': commands}
|
||
|
if opening_move:
|
||
|
conversion_date = opening_move.date
|
||
|
else:
|
||
|
move_values.update(self._get_default_opening_move_values())
|
||
|
conversion_date = move_values['date']
|
||
|
for account, (debit, credit) in to_update.items():
|
||
|
if debit is not None:
|
||
|
commands.extend(update_vals(account, 'debit', debit))
|
||
|
if credit is not None:
|
||
|
commands.extend(update_vals(account, 'credit', -credit))
|
||
|
|
||
|
commands.extend(update_vals(balancing_account, 'debit', max(-open_balance, 0), balancing=True))
|
||
|
commands.extend(update_vals(balancing_account, 'credit', -max(open_balance, 0), balancing=True))
|
||
|
|
||
|
# Nothing to do.
|
||
|
if not commands:
|
||
|
return
|
||
|
|
||
|
if opening_move:
|
||
|
opening_move.write(move_values)
|
||
|
else:
|
||
|
self.account_opening_move_id = self.env['account.move'].create(move_values)
|
||
|
|
||
|
def action_save_onboarding_sale_tax(self):
|
||
|
""" Set the onboarding step as done """
|
||
|
self.env['onboarding.onboarding.step'].action_validate_step('account.onboarding_onboarding_step_sales_tax')
|
||
|
|
||
|
def get_chart_of_accounts_or_fail(self):
|
||
|
account = self.env['account.account'].search(self.env['account.account']._check_company_domain(self), limit=1)
|
||
|
if len(account) == 0:
|
||
|
action = self.env.ref('account.action_account_config')
|
||
|
msg = _(
|
||
|
"We cannot find a chart of accounts for this company, you should configure it. \n"
|
||
|
"Please go to Account Configuration and select or install a fiscal localization.")
|
||
|
raise RedirectWarning(msg, action.id, _("Go to the configuration panel"))
|
||
|
return account
|
||
|
|
||
|
def _existing_accounting(self) -> bool:
|
||
|
"""Return True iff some accounting entries have already been made for the current company."""
|
||
|
self.ensure_one()
|
||
|
return bool(self.env['account.move.line'].search([('company_id', 'child_of', self.id)], limit=1))
|
||
|
|
||
|
def _chart_template_selection(self):
|
||
|
return self.env['account.chart.template']._select_chart_template(self.country_id)
|
||
|
|
||
|
@api.model
|
||
|
def _action_check_hash_integrity(self):
|
||
|
return self.env.ref('account.action_report_account_hash_integrity').report_action(self.id)
|
||
|
|
||
|
def _check_hash_integrity(self):
|
||
|
"""Checks that all posted moves have still the same data as when they were posted
|
||
|
and raises an error with the result.
|
||
|
"""
|
||
|
if not self.env.user.has_group('account.group_account_user'):
|
||
|
raise UserError(_('Please contact your accountant to print the Hash integrity result.'))
|
||
|
|
||
|
def build_move_info(move):
|
||
|
return(move.name, move.inalterable_hash, fields.Date.to_string(move.date))
|
||
|
|
||
|
journals = self.env['account.journal'].search(self.env['account.journal']._check_company_domain(self))
|
||
|
results_by_journal = {
|
||
|
'results': [],
|
||
|
'printing_date': format_date(self.env, fields.Date.to_string(fields.Date.context_today(self)))
|
||
|
}
|
||
|
|
||
|
for journal in journals:
|
||
|
rslt = {
|
||
|
'journal_name': journal.name,
|
||
|
'journal_code': journal.code,
|
||
|
'restricted_by_hash_table': journal.restrict_mode_hash_table and 'V' or 'X',
|
||
|
'msg_cover': '',
|
||
|
'first_hash': 'None',
|
||
|
'first_move_name': 'None',
|
||
|
'first_move_date': 'None',
|
||
|
'last_hash': 'None',
|
||
|
'last_move_name': 'None',
|
||
|
'last_move_date': 'None',
|
||
|
}
|
||
|
if not journal.restrict_mode_hash_table:
|
||
|
rslt.update({'msg_cover': _('This journal is not in strict mode.')})
|
||
|
results_by_journal['results'].append(rslt)
|
||
|
continue
|
||
|
|
||
|
# We need the `sudo()` to ensure that all the moves are searched, no matter the user's access rights.
|
||
|
# This is required in order to generate consistent hashs.
|
||
|
# It is not an issue, since the data is only used to compute a hash and not to return the actual values.
|
||
|
all_moves_count = self.env['account.move'].sudo().search_count([('state', '=', 'posted'), ('journal_id', '=', journal.id)])
|
||
|
moves = self.env['account.move'].sudo().search([('state', '=', 'posted'), ('journal_id', '=', journal.id),
|
||
|
('secure_sequence_number', '!=', 0)], order="secure_sequence_number ASC")
|
||
|
if not moves:
|
||
|
rslt.update({
|
||
|
'msg_cover': _('There isn\'t any journal entry flagged for data inalterability yet for this journal.'),
|
||
|
})
|
||
|
results_by_journal['results'].append(rslt)
|
||
|
continue
|
||
|
|
||
|
previous_hash = u''
|
||
|
start_move_info = []
|
||
|
hash_corrupted = False
|
||
|
current_hash_version = 1
|
||
|
for move in moves:
|
||
|
computed_hash = move.with_context(hash_version=current_hash_version)._compute_hash(previous_hash=previous_hash)
|
||
|
while move.inalterable_hash != computed_hash and current_hash_version < MAX_HASH_VERSION:
|
||
|
current_hash_version += 1
|
||
|
computed_hash = move.with_context(hash_version=current_hash_version)._compute_hash(previous_hash=previous_hash)
|
||
|
if move.inalterable_hash != computed_hash:
|
||
|
rslt.update({'msg_cover': _('Corrupted data on journal entry with id %s.', move.id)})
|
||
|
results_by_journal['results'].append(rslt)
|
||
|
hash_corrupted = True
|
||
|
break
|
||
|
if not previous_hash:
|
||
|
#save the date and sequence number of the first move hashed
|
||
|
start_move_info = build_move_info(move)
|
||
|
previous_hash = move.inalterable_hash
|
||
|
end_move_info = build_move_info(move)
|
||
|
|
||
|
if hash_corrupted:
|
||
|
continue
|
||
|
|
||
|
rslt.update({
|
||
|
'first_move_name': start_move_info[0],
|
||
|
'first_hash': start_move_info[1],
|
||
|
'first_move_date': format_date(self.env, start_move_info[2]),
|
||
|
'last_move_name': end_move_info[0],
|
||
|
'last_hash': end_move_info[1],
|
||
|
'last_move_date': format_date(self.env, end_move_info[2]),
|
||
|
})
|
||
|
if len(moves) == all_moves_count:
|
||
|
rslt['msg_cover'] = _('All entries are hashed.')
|
||
|
else:
|
||
|
rslt['msg_cover'] = _('Entries are hashed from %s (%s)', start_move_info[0], format_date(self.env, start_move_info[2]))
|
||
|
results_by_journal['results'].append(rslt)
|
||
|
|
||
|
return results_by_journal
|
||
|
|
||
|
@api.model
|
||
|
def _with_locked_records(self, records):
|
||
|
""" To avoid sending the same records multiple times from different transactions,
|
||
|
we use this generic method to lock the records passed as parameter.
|
||
|
|
||
|
:param records: The records to lock.
|
||
|
"""
|
||
|
if not records.ids:
|
||
|
return
|
||
|
self._cr.execute(f'SELECT * FROM {records._table} WHERE id IN %s FOR UPDATE SKIP LOCKED', [tuple(records.ids)])
|
||
|
available_ids = {r[0] for r in self._cr.fetchall()}
|
||
|
if available_ids != set(records.ids):
|
||
|
raise UserError(_("Some documents are being sent by another process already."))
|
||
|
|
||
|
def compute_fiscalyear_dates(self, current_date):
|
||
|
"""
|
||
|
Returns the dates of the fiscal year containing the provided date for this company.
|
||
|
:return: A dictionary containing:
|
||
|
* date_from
|
||
|
* date_to
|
||
|
"""
|
||
|
self.ensure_one()
|
||
|
date_from, date_to = date_utils.get_fiscal_year(current_date, day=self.fiscalyear_last_day, month=int(self.fiscalyear_last_month))
|
||
|
return {'date_from': date_from, 'date_to': date_to}
|