TLDR:
* invoices are implemented using computed methods instead of onchange
* the synchronization only happens when switching tabs in the Form view
to improve perfs.
_______________________________________________________________________
The whole engine of the synchronization of Invoices to the Journal
Entries has been refactored
* by using computed fields instead of onchange functions
* by synchronizing only from invoice to journal entry in `create` and
`write`
* by saving when switching tabs on the Invoice form, to synchronize
before showing the values
This comes with numerous advantages:
* no need to call the onchange methods manually
* no need to use the Form emulator to build invoices (i.e. EDI, OCR,
intercompany, ...)
* the performance for invoices with many lines improves drastically, going
from 2 minutes to 4 seconds to create an invoice with 500 lines
* the model is more declarative, we can now see how the values are computed
instead of having the values being copied from various places.
* remove the hack in `onchange` that disabled the recursivity of it,
which was unexpected and needed to be managed manually in all the
onchange methods
This means that:
* Some fields need to be exclusively computed on journal entries values
or invoice values, more specifically the Tax Summary widget.
It is now
- computed from entry lines, when opening the view
- computed from invoice lines when changing those, because the tax lines
will need to be recomputed anyways, erasing previously set values
- set with an inverse function when saving; after the sync has been done
* Some possible operations previously possible have been dropped.
(i.e. look at the removed test test_in_invoice_line_onchange_accounting_fields_1)
This is because such a behavior was undefined (how is changing the balance going
to affect the unit price? How is the amount currency going to affect it?)
_______________________________________________________________________
Implementation Details
----------------------
The "dynamic lines", meaning the payment terms and the tax lines are now
only created in the `create` and `write` functions.
In order to reduce code duplication, it has been implemented using
context managers used in both `account.move` and `account.move.line`
These context managers help comparing the values before/after, acting
like a local `onchange`, but getting benefit from the dirty flags from
the `compute` dependences.
This is relying on computed fields on the move (`needed_terms`) and on
the lines (`compute_all_tax`) which contain the values needed for the
related move.
Depending on the needed values and the existing values (`term_key` and
`tax_key`, respectively) the context manager will determine what needs
to be created/updated/deleted.
Some related changes are to produce a `dict` instead of a `str` for the
`tax_totals` (previously `tax_totals_json`) fields, by simplicity to
reduce the complexity of IO, and simplicity of debugging, because the
logic of the field needed to change (cannot be computed at the same time
anymore since it needed the lines to be synced)
By simplicity, and also because it makes more sense, some boolean fields
have been merged into `display_type`:
* `is_rounding_line`
* `exclude_from_invoice_tab`
* `is_anglo_saxon_line`
The `price_unit`, `quantity` and other "invoice fields" are now not set
anymore on lines that are not product lines since it didn't make any
sense to have it.
Performances
------------
You have to keep in mind that a simple `create` didn't compute a lot of
fields, for instance not taxes were set, no payment terms,...
Now it does.
```python
import random
from timeit import timeit
from odoo import Command
domain = [('company_id', 'in', (False, self.env.company.id))]
products = self.env['product.product'].search(domain).ids
partners = self.env['res.partner'].search(domain).ids
taxes = self.env['account.tax'].search(domain).ids
def create(nmove, nline):
self.env['account.move'].create([
{
'move_type': 'out_invoice',
'partner_id': random.choice(partners),
'invoice_line_ids': [
Command.create({
'name': f'line{i}',
'product_id': random.choice(products),
'tax_ids': [Command.set([random.choice(taxes)])],
})
for i in range(nline)
]
}
for j in range(nmove)
])
# After | Before
print(timeit("create(1, 1)", globals=globals(), number=1)) # 0.11 | 0.09
print(timeit("create(100, 1)", globals=globals(), number=1)) # 2.76 | 2.50
print(timeit("create(500, 1)", globals=globals(), number=1)) # 14.56 | 12.34
print(timeit("create(1, 100)", globals=globals(), number=1)) # 1.03 | 5.52
print(timeit("create(1, 500)", globals=globals(), number=1)) # 3.99 | 125.02
print(timeit("create(50, 50)", globals=globals(), number=1)) # 19.44 | 79.55
```
Another metric that can be used is running the test suite with
`--test-tags=/account` (only `account` installed)
* before: 404s, 267127 queries (366 tests)
* after: 318s, 232125 queries (362 tests)
Why this commit title?
----------------------
Someone told me that this was the perfect way of naming your commits.
c04065abd8
task-2711317
closes odoo/odoo#96134
Related: odoo/upgrade#3715
Related: odoo/enterprise#29758
Signed-off-by: Laurent Smet <las@odoo.com>
656 lines
34 KiB
Python
656 lines
34 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from datetime import timedelta, datetime, date
|
|
import calendar
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import fields, models, api, _
|
|
from odoo.exceptions import ValidationError, UserError, RedirectWarning
|
|
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
|
|
|
|
|
|
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'),
|
|
]
|
|
|
|
ONBOARDING_STEP_STATES = [
|
|
('not_done', "Not done"),
|
|
('just_done', "Just done"),
|
|
('done', "Done"),
|
|
]
|
|
DASHBOARD_ONBOARDING_STATES = ONBOARDING_STEP_STATES + [('closed', 'Closed')]
|
|
|
|
|
|
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.")
|
|
transfer_account_id = fields.Many2one('account.account',
|
|
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_id = fields.Many2one('account.chart.template', help='The chart template for the company (if any)')
|
|
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 Account")
|
|
default_cash_difference_expense_account_id = fields.Many2one('account.account', string="Cash Difference Expense Account")
|
|
account_journal_suspense_account_id = fields.Many2one('account.account', string='Journal Suspense Account')
|
|
account_journal_payment_debit_account_id = fields.Many2one('account.account', string='Journal Outstanding Receipts Account')
|
|
account_journal_payment_credit_account_id = fields.Many2one('account.account', string='Journal Outstanding Payments Account')
|
|
transfer_account_code_prefix = fields.Char(string='Prefix of the transfer accounts')
|
|
account_sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax")
|
|
account_purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax")
|
|
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",
|
|
domain="[('account_type', 'not in', ('asset_receivable','liability_payable','asset_cash', 'liability_credit_card')), ('deprecated', '=', False), ('company_id', '=', id), \
|
|
('account_type', 'in', ('income', 'income_other'))]")
|
|
expense_currency_exchange_account_id = fields.Many2one(
|
|
comodel_name='account.account',
|
|
string="Loss Exchange Rate Account",
|
|
domain="[('account_type', 'not in', ('asset_receivable','liability_payable','asset_cash', 'liability_credit_card')), ('deprecated', '=', False), ('company_id', '=', id), \
|
|
('account_type', '=', 'expense')]")
|
|
anglo_saxon_accounting = fields.Boolean(string="Use anglo-saxon accounting")
|
|
property_stock_account_input_categ_id = fields.Many2one('account.account', string="Input Account for Stock Valuation")
|
|
property_stock_account_output_categ_id = fields.Many2one('account.account', string="Output Account for Stock Valuation")
|
|
property_stock_valuation_account_id = fields.Many2one('account.account', string="Account Template for Stock Valuation")
|
|
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_print = fields.Boolean('Print by default', default=True)
|
|
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.")
|
|
|
|
# Fields marking the completion of a setup step
|
|
account_setup_bank_data_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding bank data step", default='not_done')
|
|
account_setup_fy_data_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding fiscal year step", default='not_done')
|
|
account_setup_coa_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding charts of account step", default='not_done')
|
|
account_setup_taxes_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding Taxes step", default='not_done')
|
|
account_onboarding_invoice_layout_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding invoice layout step", default='not_done')
|
|
account_onboarding_create_invoice_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding create invoice step", default='not_done')
|
|
account_onboarding_sale_tax_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding sale tax step", default='not_done')
|
|
|
|
# account dashboard onboarding
|
|
account_invoice_onboarding_state = fields.Selection(DASHBOARD_ONBOARDING_STATES, string="State of the account invoice onboarding panel", default='not_done')
|
|
account_dashboard_onboarding_state = fields.Selection(DASHBOARD_ONBOARDING_STATES, string="State of the account dashboard onboarding panel", default='not_done')
|
|
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,
|
|
compute='_compute_invoice_terms_html', store=True, readonly=False)
|
|
|
|
account_setup_bill_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding bill step", default='not_done')
|
|
|
|
# Needed in the Point of Sale
|
|
account_default_pos_receivable_account_id = fields.Many2one('account.account', string="Default PoS Receivable Account")
|
|
|
|
# Accrual Accounting
|
|
expense_accrual_account_id = fields.Many2one('account.account',
|
|
help="Account used to move the period of an expense",
|
|
domain="[('internal_group', '=', 'liability'), ('account_type', 'not in', ('asset_receivable', 'liability_payable')), ('company_id', '=', id)]")
|
|
revenue_accrual_account_id = fields.Many2one('account.account',
|
|
help="Account used to move the period of a revenue",
|
|
domain="[('internal_group', '=', 'asset'), ('account_type', 'not in', ('asset_receivable', 'liability_payable')), ('company_id', '=', id)]")
|
|
automatic_entry_default_journal_id = fields.Many2one('account.journal', help="Journal used by default for moving the period of an entry", domain="[('type', '=', 'general')]")
|
|
|
|
# Technical field to hide country specific fields in company form view
|
|
country_code = fields.Char(related='country_id.code')
|
|
|
|
# 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',
|
|
string="Cash Basis Journal")
|
|
account_cash_basis_base_account_id = fields.Many2one(
|
|
comodel_name='account.account',
|
|
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)
|
|
|
|
# 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")
|
|
|
|
@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('country_id')
|
|
def compute_account_tax_fiscal_country(self):
|
|
for record in self:
|
|
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:
|
|
foreign_vat_fpos = self.env['account.fiscal.position'].search([('company_id', '=', record.id), ('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
|
|
|
|
def get_and_update_account_invoice_onboarding_state(self):
|
|
""" This method is called on the controller rendering method and ensures that the animations
|
|
are displayed only one time. """
|
|
return self.get_and_update_onbarding_state(
|
|
'account_invoice_onboarding_state',
|
|
self.get_account_invoice_onboarding_steps_states_names()
|
|
)
|
|
|
|
# YTI FIXME: Define only one method that returns {'account': [], 'sale': [], ...}
|
|
def get_account_invoice_onboarding_steps_states_names(self):
|
|
""" Necessary to add/edit steps from other modules (payment acquirer in this case). """
|
|
return [
|
|
'base_onboarding_company_state',
|
|
'account_onboarding_invoice_layout_state',
|
|
'account_onboarding_create_invoice_state',
|
|
]
|
|
|
|
def get_and_update_account_dashboard_onboarding_state(self):
|
|
""" This method is called on the controller rendering method and ensures that the animations
|
|
are displayed only one time. """
|
|
return self.get_and_update_onbarding_state(
|
|
'account_dashboard_onboarding_state',
|
|
self.get_account_dashboard_onboarding_steps_states_names()
|
|
)
|
|
|
|
def get_account_dashboard_onboarding_steps_states_names(self):
|
|
""" Necessary to add/edit steps from other modules (account_winbooks_import in this case). """
|
|
return [
|
|
'account_setup_bank_data_state',
|
|
'account_setup_fy_data_state',
|
|
'account_setup_coa_state',
|
|
'account_setup_taxes_state',
|
|
]
|
|
|
|
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):
|
|
accounts = self.env['account.account'].search([('code', 'like', old_code), ('account_type', 'in', ('asset_cash', 'liability_credit_card')),
|
|
('company_id', '=', self.id)], order='code asc')
|
|
for account in accounts:
|
|
if account.code.startswith(old_code):
|
|
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.
|
|
"""
|
|
statements = unreconciled_statement_lines.statement_id
|
|
action = {
|
|
'name': _("Unreconciled Statements"),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.bank.statement',
|
|
'context': {'create': False},
|
|
}
|
|
if len(statements) == 1:
|
|
action.update({
|
|
'view_mode': 'form',
|
|
'res_id': statements.id,
|
|
})
|
|
else:
|
|
action.update({
|
|
'view_mode': 'list,form',
|
|
'domain': [('id', 'in', statements.ids)],
|
|
})
|
|
return action
|
|
|
|
def _validate_fiscalyear_lock(self, values):
|
|
if values.get('fiscalyear_lock_date'):
|
|
|
|
draft_entries = self.env['account.move'].search([
|
|
('company_id', 'in', 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', 'in', 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"""
|
|
self.ensure_one()
|
|
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
|
|
return lock_date
|
|
|
|
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 self.env['account.move.line'].search([('company_id', '=', company.id)]):
|
|
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."""
|
|
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 setting_init_fiscal_year_action(self):
|
|
""" Called by the 'Fiscal Year Opening' button of the setup bar."""
|
|
company = self.env.company
|
|
company.create_op_move_if_non_existant()
|
|
new_wizard = self.env['account.financial.year.op'].create({'company_id': company.id})
|
|
view_id = self.env.ref('account.setup_financial_year_opening_form').id
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Accounting Periods'),
|
|
'view_mode': 'form',
|
|
'res_model': 'account.financial.year.op',
|
|
'target': 'new',
|
|
'res_id': new_wizard.id,
|
|
'views': [[view_id, 'form']],
|
|
}
|
|
|
|
@api.model
|
|
def setting_chart_of_accounts_action(self):
|
|
""" Called by the 'Chart of Accounts' button of the setup bar."""
|
|
company = self.env.company
|
|
company.sudo().set_onboarding_step_done('account_setup_coa_state')
|
|
|
|
# If an opening move has already been posted, we open the tree view showing all the accounts
|
|
if company.opening_move_posted():
|
|
return 'account.action_account_form'
|
|
|
|
# Otherwise, we create the opening move
|
|
company.create_op_move_if_non_existant()
|
|
|
|
# Then, we open will open a custom tree view allowing to edit opening balances of the account
|
|
view_id = self.env.ref('account.init_accounts_tree').id
|
|
# Hide the current year earnings account as it is automatically computed
|
|
domain = [('account_type', '!=', 'equity_unaffected'), ('company_id', '=', company.id)]
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Chart of Accounts'),
|
|
'res_model': 'account.account',
|
|
'view_mode': 'tree',
|
|
'limit': 99999999,
|
|
'search_view_id': [self.env.ref('account.view_account_search').id],
|
|
'views': [[view_id, 'list']],
|
|
'domain': domain,
|
|
}
|
|
|
|
@api.model
|
|
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).
|
|
"""
|
|
self.ensure_one()
|
|
if not self.account_opening_move_id:
|
|
default_journal = self.env['account.journal'].search([('type', '=', 'general'), ('company_id', '=', self.id)], limit=1)
|
|
|
|
if not default_journal:
|
|
raise UserError(_("Please install a chart of accounts or create a miscellaneous journal before proceeding."))
|
|
|
|
opening_date = self.account_opening_date - timedelta(days=1)
|
|
|
|
self.account_opening_move_id = self.env['account.move'].create({
|
|
'ref': _('Opening Journal Entry'),
|
|
'company_id': self.id,
|
|
'journal_id': default_journal.id,
|
|
'date': opening_date,
|
|
})
|
|
|
|
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([('company_id', '=', self.id),
|
|
('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([('code', '=', str(code)), ('company_id', '=', self.id)]):
|
|
code -= 1
|
|
return self.env['account.account'].create({
|
|
'code': str(code),
|
|
'name': _('Undistributed Profits/Losses'),
|
|
'account_type': unaffected_earnings_type,
|
|
'company_id': self.id,
|
|
})
|
|
|
|
def get_opening_move_differences(self, opening_move_lines):
|
|
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.
|
|
"""
|
|
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.with_context(check_move_validity=False).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,
|
|
})
|
|
|
|
@api.model
|
|
def action_close_account_invoice_onboarding(self):
|
|
""" Mark the invoice onboarding panel as closed. """
|
|
self.env.company.account_invoice_onboarding_state = 'closed'
|
|
|
|
@api.model
|
|
def action_close_account_dashboard_onboarding(self):
|
|
""" Mark the dashboard onboarding panel as closed. """
|
|
self.env.company.account_dashboard_onboarding_state = 'closed'
|
|
|
|
@api.model
|
|
def action_open_account_onboarding_sale_tax(self):
|
|
""" Onboarding step for the invoice layout. """
|
|
action = self.env["ir.actions.actions"]._for_xml_id("account.action_open_account_onboarding_sale_tax")
|
|
action['res_id'] = self.env.company.id
|
|
return action
|
|
|
|
@api.model
|
|
def action_open_account_onboarding_create_invoice(self):
|
|
action = self.env["ir.actions.actions"]._for_xml_id("account.action_open_account_onboarding_create_invoice")
|
|
return action
|
|
|
|
@api.model
|
|
def action_open_taxes_onboarding(self):
|
|
""" Called by the 'Taxes' button of the setup bar."""
|
|
|
|
company = self.env.company
|
|
company.sudo().set_onboarding_step_done('account_setup_taxes_state')
|
|
view_id_list = self.env.ref('account.view_onboarding_tax_tree').id
|
|
view_id_form = self.env.ref('account.view_tax_form').id
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Taxes'),
|
|
'res_model': 'account.tax',
|
|
'target': 'current',
|
|
'views': [[view_id_list, 'list'], [view_id_form, 'form']],
|
|
'context': {'search_default_sale': True, 'search_default_purchase': True, 'active_test': False},
|
|
}
|
|
|
|
def action_save_onboarding_invoice_layout(self):
|
|
""" Set the onboarding step as done """
|
|
if bool(self.external_report_layout_id):
|
|
self.set_onboarding_step_done('account_onboarding_invoice_layout_state')
|
|
|
|
def action_save_onboarding_sale_tax(self):
|
|
""" Set the onboarding step as done """
|
|
self.set_onboarding_step_done('account_onboarding_sale_tax_state')
|
|
|
|
def get_chart_of_accounts_or_fail(self):
|
|
account = self.env['account.account'].search([('company_id', '=', self.id)], 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
|
|
|
|
@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.
|
|
"""
|
|
def build_move_info(move):
|
|
return(move.name, move.inalterable_hash, fields.Date.to_string(move.date))
|
|
|
|
journals = self.env['account.journal'].search([('company_id', '=', self.id)])
|
|
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
|
|
|
|
all_moves_count = self.env['account.move'].search_count([('state', '=', 'posted'), ('journal_id', '=', journal.id)])
|
|
moves = self.env['account.move'].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
|
|
for move in moves:
|
|
if move.inalterable_hash != move._compute_hash(previous_hash=previous_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.update({'msg_cover': _('All entries are hashed.')})
|
|
else:
|
|
rslt.update({'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
|
|
|
|
def compute_fiscalyear_dates(self, current_date):
|
|
"""
|
|
The role of this method is to provide a fallback when account_accounting is not installed.
|
|
As the fiscal year is irrelevant when account_accounting is not installed, this method returns the calendar year.
|
|
:param current_date: A datetime.date/datetime.datetime object.
|
|
:return: A dictionary containing:
|
|
* date_from
|
|
* date_to
|
|
"""
|
|
|
|
return {'date_from': datetime(year=current_date.year, month=1, day=1).date(),
|
|
'date_to': datetime(year=current_date.year, month=12, day=31).date()}
|