[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
This commit is contained in:
Laurent Smet
2018-06-08 14:23:14 +02:00
committed by qdp-odoo
parent d6d3081953
commit d49ab568a2
7 changed files with 416 additions and 5 deletions
+1
View File
@@ -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
+6
View File
@@ -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}
+144
View File
@@ -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 !'),
+1
View File
@@ -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
+64 -5
View File
@@ -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):
@@ -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'])
+8
View File
@@ -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"/>