diff options
| author | Laurent Smet <las@openerp.com> | 2018-06-08 14:16:39 +0200 |
|---|---|---|
| committer | qdp-odoo <qdp@odoo.com> | 2018-06-08 14:23:14 +0200 |
| commit | d49ab568a2cc5231ac12267a9e74eaf16ca45f9e (patch) | |
| tree | dce3cbc91264a967fcc6015fc9ccc9aeebbe58dc | |
| parent | d6d30819530b02085c714487888318e79e914f8c (diff) | |
[IMP] account: compute tax lines on account.move with onchange
Allow the user to set taxes on lines when creating manually a new journal entry, and tax lines will be computed automatically thanks to an onchange based on new unstored fields.
Was task: 33802
Was PR #19029
| -rw-r--r-- | addons/account/__init__.py | 1 | ||||
| -rw-r--r-- | addons/account/models/account.py | 6 | ||||
| -rw-r--r-- | addons/account/models/account_move.py | 144 | ||||
| -rw-r--r-- | addons/account/tests/__init__.py | 1 | ||||
| -rw-r--r-- | addons/account/tests/account_test_classes.py | 69 | ||||
| -rw-r--r-- | addons/account/tests/test_account_move_taxes_edition.py | 192 | ||||
| -rw-r--r-- | addons/account/views/account_view.xml | 8 |
7 files changed, 416 insertions, 5 deletions
diff --git a/addons/account/__init__.py b/addons/account/__init__.py index dc3a8f3e476..46a5df4eaa9 100644 --- a/addons/account/__init__.py +++ b/addons/account/__init__.py @@ -5,6 +5,7 @@ from . import controllers from . import models from . import wizard from . import report +from . import tests from odoo import api, SUPERUSER_ID diff --git a/addons/account/models/account.py b/addons/account/models/account.py index c8ec7e289ff..75dbc29b6d4 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -885,6 +885,12 @@ class AccountTax(models.Model): default = dict(default or {}, name=_("%s (Copy)") % self.name) return super(AccountTax, self).copy(default=default) + @api.depends('name', 'type_tax_use') + def name_get(self): + if not self._context.get('append_type_to_tax_name'): + return super(AccountTax, self).name_get() + return [(tax.id, '%s (%s)' % (tax.name, tax.type_tax_use)) for tax in self] + @api.model def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): """ Returns a list of tuples containing id, name, as internally it is called {def name_get} diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index e50cc97fb24..d2e555dcdbc 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -115,6 +115,142 @@ class AccountMove(models.Model): auto_reverse = fields.Boolean(string='Reverse Automatically', default=False, help='If this checkbox is ticked, this entry will be automatically reversed at the reversal date you defined.') reverse_date = fields.Date(string='Reversal Date', help='Date of the reverse accounting entry.') reverse_entry_id = fields.Many2one('account.move', String="Reverse entry", store=True, readonly=True) + tax_type_domain = fields.Char(store=False, help='Technical field used to have a dynamic taxes domain on the form view.') + + @api.onchange('journal_id') + def _onchange_journal_id(self): + self.tax_type_domain = self.journal_id.type if self.journal_id.type in ('sale', 'purchase') else None + + @api.onchange('line_ids') + def _onchange_line_ids(self): + '''Compute additional lines corresponding to the taxes set on the line_ids. + + For example, add a line with 1000 debit and 15% tax, this onchange will add a new + line with 150 debit. + ''' + def _str_to_list(string): + #remove heading and trailing brackets and return a list of int. This avoid calling safe_eval on untrusted field content + string = string[1:-1] + if string: + return [int(x) for x in string.split(',')] + return [] + + def _build_grouping_key(line): + #build a string containing all values used to create the tax line + return str(line.tax_ids.ids) + '-' + str(line.analytic_tag_ids.ids) + '-' + (line.analytic_account_id and str(line.analytic_account_id.id) or '') + + def _parse_grouping_key(line): + # Retrieve values computed the last time this method has been run. + if not line.tax_line_grouping_key: + return {'tax_ids': [], 'tag_ids': [], 'analytic_account_id': False} + tax_str, tags_str, analytic_account_str = line.tax_line_grouping_key.split('-') + return { + 'tax_ids': _str_to_list(tax_str), + 'tag_ids': _str_to_list(tags_str), + 'analytic_account_id': analytic_account_str and int(analytic_account_str) or False, + } + + def _find_existing_tax_line(line_ids, tax, tag_ids, analytic_account_id): + if tax.analytic: + return line_ids.filtered(lambda x: x.tax_line_id == tax and x.analytic_tag_ids.ids == tag_ids and x.analytic_account_id.id == analytic_account_id) + return line_ids.filtered(lambda x: x.tax_line_id == tax) + + def _get_lines_to_sum(line_ids, tax, tag_ids, analytic_account_id): + if tax.analytic: + return line_ids.filtered(lambda x: tax in x.tax_ids and x.analytic_tag_ids.ids == tag_ids and x.analytic_account_id.id == analytic_account_id) + return line_ids.filtered(lambda x: tax in x.tax_ids) + + def _get_tax_account(tax, amount): + if tax.tax_exigibility == 'on_payment' and tax.cash_basis_account: + return tax.cash_basis_account + if tax.type_tax_use == 'purchase': + return tax.refund_account_id if amount < 0 else tax.account_id + return tax.refund_account_id if amount >= 0 else tax.account_id + + # Cache the already computed tax to avoid useless recalculation. + processed_taxes = self.env['account.tax'] + + self.ensure_one() + for line in self.line_ids.filtered(lambda x: x.recompute_tax_line): + # Retrieve old field values. + parsed_key = _parse_grouping_key(line) + + # Unmark the line. + line.recompute_tax_line = False + + # Manage group of taxes. + group_taxes = line.tax_ids.filtered(lambda t: t.amount_type == 'group') + children_taxes = group_taxes.mapped('children_tax_ids') + if children_taxes: + line.tax_ids += children_taxes - line.tax_ids + # Because the taxes on the line changed, we need to recompute them. + processed_taxes -= children_taxes + + # Get the taxes to process. + taxes = self.env['account.tax'].browse(parsed_key['tax_ids']) + taxes += line.tax_ids.filtered(lambda t: t not in taxes) + taxes += children_taxes.filtered(lambda t: t not in taxes) + to_process_taxes = (taxes - processed_taxes).filtered(lambda t: t.amount_type != 'group') + processed_taxes += to_process_taxes + + # Process taxes. + for tax in to_process_taxes: + tax_line = _find_existing_tax_line(self.line_ids, tax, parsed_key['tag_ids'], parsed_key['analytic_account_id']) + lines_to_sum = _get_lines_to_sum(self.line_ids, tax, parsed_key['tag_ids'], parsed_key['analytic_account_id']) + + if not lines_to_sum: + # Drop tax line because the originator tax is no longer used. + self.line_ids -= tax_line + continue + + balance = sum([l.balance for l in lines_to_sum]) + + # Compute the tax amount one by one. + quantity = len(lines_to_sum) if tax.amount_type == 'fixed' else 1 + taxes_vals = tax.compute_all(balance, + quantity=quantity, currency=line.currency_id, product=line.product_id, partner=line.partner_id) + + if tax_line: + # Update the existing tax_line. + if balance: + # Update the debit/credit amount according to the new balance. + if taxes_vals.get('taxes'): + amount = taxes_vals['taxes'][0]['amount'] + account = _get_tax_account(tax, amount) or line.account_id + tax_line.debit = amount > 0 and amount or 0.0 + tax_line.credit = amount < 0 and -amount or 0.0 + tax_line.account_id = account + else: + # Reset debit/credit in case of the originator line is temporary set to 0 in both debit/credit. + tax_line.debit = tax_line.credit = 0.0 + elif taxes_vals.get('taxes'): + # Create a new tax_line. + + amount = taxes_vals['taxes'][0]['amount'] + account = _get_tax_account(tax, amount) or line.account_id + tax_vals = taxes_vals['taxes'][0] + + name = tax_vals['name'] + line_vals = { + 'account_id': account.id, + 'name': name, + 'tax_line_id': tax_vals['id'], + 'partner_id': line.partner_id.id, + 'debit': amount > 0 and amount or 0.0, + 'credit': amount < 0 and -amount or 0.0, + 'analytic_account_id': line.analytic_account_id.id if tax.analytic else False, + 'analytic_tag_ids': line.analytic_tag_ids.ids if tax.analytic else False, + 'move_id': self.id, + 'tax_exigible': tax.tax_exigibility == 'on_invoice', + 'company_id': self.company_id.id, + 'company_currency_id': self.company_id.currency_id.id, + } + # N.B. currency_id/amount_currency are not set because if we have two lines with the same tax + # and different currencies, we have no idea which currency set on this line. + self.env['account.move.line'].new(line_vals) + + # Keep record of the values used as taxes the last time this method has been run. + line.tax_line_grouping_key = _build_grouping_key(line) @api.model def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): @@ -304,6 +440,11 @@ class AccountMoveLine(models.Model): _description = "Journal Item" _order = "date desc, id desc" + @api.onchange('debit', 'credit', 'tax_ids', 'analytic_account_id', 'analytic_tag_ids') + def onchange_tax_ids_create_aml(self): + for line in self: + line.recompute_tax_line = True + @api.model_cr def init(self): """ change index on partner_id to a multi-column index on (partner_id, ref), the new index will behave in the @@ -484,6 +625,9 @@ class AccountMoveLine(models.Model): #Needed for setup, as a decoration attribute needs to know that for a tree view in one of the popups, and there's no way to reference directly a xml id from there is_unaffected_earnings_line = fields.Boolean(string="Is Unaffected Earnings Line", compute="_compute_is_unaffected_earnings_line", help="Tells whether or not this line belongs to an unaffected earnings account") + recompute_tax_line = fields.Boolean(store=False, help="Technical field used to know if the tax_ids field has been modified in the UI.") + tax_line_grouping_key = fields.Char(store=False, string='Old Taxes', help="Technical field used to store the old values of fields used to compute tax lines (in account.move form view) between the moment the user changed it and the moment the ORM reflects that change in its one2many") + _sql_constraints = [ ('credit_debit1', 'CHECK (credit*debit=0)', 'Wrong credit or debit value in accounting entry !'), ('credit_debit2', 'CHECK (credit+debit>=0)', 'Wrong credit or debit value in accounting entry !'), diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 750cddd82fa..3f11d4c9721 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -15,5 +15,6 @@ from . import test_reconciliation from . import test_search from . import test_setup_bar from . import test_tax +from . import test_account_move_taxes_edition from . import test_templates_consistency from . import test_account_fiscal_year diff --git a/addons/account/tests/account_test_classes.py b/addons/account/tests/account_test_classes.py index c7aff306c02..b59ec390f67 100644 --- a/addons/account/tests/account_test_classes.py +++ b/addons/account/tests/account_test_classes.py @@ -5,6 +5,7 @@ _logger = logging.getLogger(__name__) from odoo.tests.common import HttpCase, tagged from odoo.exceptions import ValidationError +from odoo.tools import float_is_zero class AccountingTestCase(HttpCase): @@ -21,15 +22,73 @@ class AccountingTestCase(HttpCase): _logger.warn('Test skipped because there is no chart of account defined ...') self.skipTest("No Chart of account found") - def check_complete_move(self, move, theorical_lines): + def check_complete_move(self, move, theorical_lines, fields_name=None): + ''' Compare the account.move lines with theorical_lines represented + as a list of lines containing values sorted using fields_name. + + :param move: An account.move record. + :param theorical_lines: A list of lines. Each line is itself a list of values. + N.B: relational fields are represented using their ids. + :param fields_name: An optional list of field's names to perform the comparison. + By default, this param is considered as ['name', 'debit', 'credit']. + :return: True if success. Otherwise, a ValidationError is raised. + ''' + def _get_theorical_line(aml, theorical_lines, fields_list): + # Search for a line matching the aml parameter. + aml_currency = aml.currency_id or aml.company_currency_id + for line in theorical_lines: + field_index = 0 + match = True + for f in fields_list: + line_value = line[field_index] + aml_value = getattr(aml, f.name) + + if f.ttype == 'float': + if not float_is_zero(aml_value - line_value): + match = False + break + elif f.ttype == 'monetary': + if aml_currency.compare_amounts(aml_value, line_value): + match = False + break + elif f.ttype in ('one2many', 'many2many'): + if not sorted(aml_value.ids) == sorted(line_value or []): + match = False + break + elif f.ttype == 'many2one': + if (line_value or aml_value) and aml_value.id != line_value: + match = False + break + elif (line_value or aml_value) and line_value != aml_value: + match = False + break + + field_index += 1 + if match: + return line + return None + + if not fields_name: + # Handle the old behavior by using arbitrary these 3 fields by default. + fields_name = ['name', 'debit', 'credit'] + + if len(move.line_ids) != len(theorical_lines): + raise ValidationError('Too many lines to compare: %d != %d.' % (len(move.line_ids), len(theorical_lines))) + + fields = self.env['ir.model.fields'].search([('name', 'in', fields_name), ('model', '=', 'account.move.line')]) + fields_map = dict((f.name, f) for f in fields) + fields_list = [fields_map[f] for f in fields_name] + for aml in move.line_ids: - line = (aml.name, round(aml.debit, 2), round(aml.credit, 2)) - if line in theorical_lines: + line = _get_theorical_line(aml, theorical_lines, fields_list) + + if line: theorical_lines.remove(line) else: - raise ValidationError('Unexpected journal item. (label: %s, debit: %s, credit: %s)' % (aml.name, round(aml.debit, 2), round(aml.credit, 2))) + raise ValidationError('Unexpected journal item. %s' % str([getattr(aml, f) for f in fields_name])) + if theorical_lines: - raise ValidationError('Remaining theorical line (not found). %s)' % ([(aml[0], aml[1], aml[2]) for aml in theorical_lines])) + raise ValidationError('Remaining theorical line (not found). %s)' % str(theorical_lines)) return True def ensure_account_property(self, property_name): diff --git a/addons/account/tests/test_account_move_taxes_edition.py b/addons/account/tests/test_account_move_taxes_edition.py new file mode 100644 index 00000000000..112d475ba29 --- /dev/null +++ b/addons/account/tests/test_account_move_taxes_edition.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.tests import tagged +from odoo.tests.common import Form + + +@tagged('post_install', '-at_install') +class TestAccountMoveTaxesEdition(AccountingTestCase): + + def setUp(self): + super(AccountingTestCase, self).setUp() + self.percent_tax = self.env['account.tax'].create({ + 'name': 'tax_line', + 'amount_type': 'percent', + 'amount': 10, + }) + self.account = self.env['account.account'].search([('deprecated', '=', False)], limit=1) + self.journal = self.env['account.journal'].search([], limit=1) + + def test_onchange_taxes_1(self): + ''' + Test an account.move.line is created automatically when adding a tax. + This test uses the following scenario: + - Create manually a debit line of 1000 having a tax. + - Assume a line containing the tax amount is created automatically. + - Create manually a credit line to balance the two previous lines. + - Save the move. + + tax = 10% + + Name | Debit | Credit | Tax_ids | Tax_line_id's name + ----------------|-----------|-----------|---------------|------------------- + debit_line_1 | 1000 | | tax | + tax_line | 100 | | | tax_line + debit_line_1 | | 1100 | | + ''' + move_form = Form(self.env['account.move'], view='account.view_move_form') + move_form.ref = 'azerty' + move_form.journal_id = self.journal + + # Create a new account.move.line with debit amount. + with move_form.line_ids.new() as debit_line: + debit_line.name = 'debit_line_1' + debit_line.account_id = self.account + debit_line.debit = 1000 + debit_line.tax_ids.clear() + debit_line.tax_ids.add(self.percent_tax) + + self.assertTrue(debit_line.recompute_tax_line) + + # Create a third account.move.line with credit amount. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'credit_line_1' + credit_line.account_id = self.account + credit_line.credit = 1100 + + move = move_form.save() + + self.check_complete_move(move, [ + ['debit_line_1', 1000, 0, [self.percent_tax.id], None], + ['tax_line', 100, 0, None, self.percent_tax.id], + ['credit_line_1', 0, 1100, None, None], + ], fields_name=['name', 'debit', 'credit', 'tax_ids', 'tax_line_id']) + + def test_onchange_taxes_2(self): + ''' + Test the amount of tax account.move.line is adapted when editing the account.move.line amount. + This test uses the following scenario: + - Create manually a debit line of 1000 having a tax. + - Assume a line containing the tax amount is created automatically. + - Set the debit amount to 2000 in the first created line. + - Assume the line containing the tax amount has been updated automatically. + - Create manually a credit line to balance the two previous lines. + - Save the move. + + tax = 10% + + Name | Debit | Credit | Tax_ids | Tax_line_id's name + ----------------|-----------|-----------|---------------|------------------- + debit_line_1 | 2000 | | tax | + tax_line | 200 | | | tax_line + debit_line_1 | | 2200 | | + ''' + move_form = Form(self.env['account.move'], view='account.view_move_form') + move_form.ref = 'azerty' + move_form.journal_id = self.journal + + # Create a new account.move.line with debit amount. + with move_form.line_ids.new() as debit_line: + debit_line.name = 'debit_line_1' + debit_line.account_id = self.account + debit_line.debit = 1000 + debit_line.tax_ids.clear() + debit_line.tax_ids.add(self.percent_tax) + + self.assertTrue(debit_line.recompute_tax_line) + + debit_line.debit = 2000 + + self.assertTrue(debit_line.recompute_tax_line) + + # Create a third account.move.line with credit amount. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'credit_line_1' + credit_line.account_id = self.account + credit_line.credit = 2200 + + move = move_form.save() + + self.check_complete_move(move, [ + ['debit_line_1', 2000, 0, [self.percent_tax.id], None], + ['tax_line', 200, 0, None, self.percent_tax.id], + ['credit_line_1', 0, 2200, None, None], + ], fields_name=['name', 'debit', 'credit', 'tax_ids', 'tax_line_id']) + + def test_onchange_taxes_3(self): + ''' + Test the amount of tax account.move.line is still editable manually. + Test the amount of tax account.move.line is cumulative for the same tax. + This test uses the following scenario: + - Create manually a debit line of 1000 having a tax. + - Assume a line containing the tax amount is created automatically. + - Edit the tax line amount of the auto-generated line by adding 5. + - Create manually a credit line to balance the two previous lines. + - Save the move. + - Edit the move. + - Create manually a debit line of 2000 having the same tax. + - Assume the line containing the tax amount has been updated (no new line created). + - Create manually a credit line to balance the four previous lines. + - Save the move. + + tax = 10% + + Name | Debit | Credit | Tax_ids | Tax_line_id's name + ----------------|-----------|-----------|---------------|------------------- + debit_line_1 | 1000 | | tax | + tax_line | 300 | | | tax_line + credit_line_1 | | 1105 | | + debit_line_2 | 2000 | | tax | + credit_line_2 | | 2195 | | + ''' + move_form = Form(self.env['account.move'], view='account.view_move_form') + move_form.ref = 'azerty' + move_form.journal_id = self.journal + + # Create a new account.move.line with debit amount. + with move_form.line_ids.new() as debit_line: + debit_line.name = 'debit_line_1' + debit_line.account_id = self.account + debit_line.debit = 1000 + debit_line.tax_ids.clear() + debit_line.tax_ids.add(self.percent_tax) + + self.assertTrue(debit_line.recompute_tax_line) + + # Edit the tax account.move.line + with move_form.line_ids.edit(index=1) as tax_line: + tax_line.debit = 105 # Was 100 + + # Create a third account.move.line with credit amount. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'credit_line_1' + credit_line.account_id = self.account + credit_line.credit = 1105 + + move = move_form.save() + + move_form = Form(move, view='account.view_move_form') + # Create a new account.move.line with debit amount. + with move_form.line_ids.new() as debit_line2: + debit_line2.name = 'debit_line_2' + debit_line2.account_id = self.account + debit_line2.debit = 2000 + debit_line2.tax_ids.clear() + debit_line2.tax_ids.add(self.percent_tax) + + self.assertTrue(debit_line2.recompute_tax_line) + + with move_form.line_ids.new() as credit_line2: + credit_line2.name = 'credit_line_2' + credit_line2.account_id = self.account + credit_line2.credit = 2195 + + move = move_form.save() + + self.check_complete_move(move, [ + ['debit_line_1', 1000, 0, [self.percent_tax.id], None], + ['tax_line', 300, 0, None, self.percent_tax.id], + ['credit_line_1', 0, 1105, None, None], + ['debit_line_2', 2000, 0, [self.percent_tax.id], None], + ['credit_line_2', 0, 2195, None, None], + ], fields_name=['name', 'debit', 'credit', 'tax_ids', 'tax_line_id']) diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index f8ad1e50132..062054e337f 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -1507,6 +1507,7 @@ <field name="state" widget="statusbar"/> </header> <sheet> + <field name="tax_type_domain" invisible="1"/> <field name="id" invisible="1"/> <div class="oe_button_box"> <button name="action_view_reverse_entry" @@ -1543,6 +1544,7 @@ <field name="line_ids" widget="one2many_list" context="{'line_ids': line_ids, 'journal_id': journal_id }"> <tree editable="bottom" string="Journal Items"> + <field name="tax_line_id" invisible="1"/> <field name="account_id" domain="[('company_id', '=', parent.company_id), ('deprecated', '=', False)]"/> <field name="partner_id" domain="['|', ('parent_id', '=', False), ('is_company', '=', True)]"/> @@ -1555,7 +1557,13 @@ <field name="currency_id" options="{'no_create': True}" groups="base.group_multi_currency"/> <field name="debit" sum="Total Debit"/> <field name="credit" sum="Total Credit"/> + <field name="tax_ids" string="Taxes Applied" widget="many2many_tags" + domain="[('type_tax_use', '=?', parent.tax_type_domain)]" + context="{'append_type_to_tax_name': not parent.tax_type_domain}" + options="{'no_create': True}"/> <field name="date_maturity" required="0" invisible="context.get('view_no_maturity', False)"/> + <field name="recompute_tax_line" invisible="1" readonly="1"/> + <field name="tax_line_grouping_key" invisible="1" readonly="1"/> </tree> </field> <field name="narration" colspan="4" placeholder="Add an internal note..." nolabel="1" height="50"/> |
