Skip to content

Commit

Permalink
[IMP] account: compute tax lines on account.move with onchange
Browse files Browse the repository at this point in the history
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 odoo#19029
  • Loading branch information
smetl authored and qdp-odoo committed Jun 8, 2018
1 parent d6d3081 commit d49ab56
Show file tree
Hide file tree
Showing 7 changed files with 416 additions and 5 deletions.
1 change: 1 addition & 0 deletions addons/account/__init__.py
Expand Up @@ -5,6 +5,7 @@
from . import models
from . import wizard
from . import report
from . import tests

from odoo import api, SUPERUSER_ID

Expand Down
6 changes: 6 additions & 0 deletions addons/account/models/account.py
Expand Up @@ -885,6 +885,12 @@ def copy(self, default=None):
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}
Expand Down
144 changes: 144 additions & 0 deletions addons/account/models/account_move.py
Expand Up @@ -115,6 +115,142 @@ def _onchange_date(self):
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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -484,6 +625,9 @@ def _get_counterpart(self):
#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 !'),
Expand Down
1 change: 1 addition & 0 deletions addons/account/tests/__init__.py
Expand Up @@ -15,5 +15,6 @@
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
69 changes: 64 additions & 5 deletions addons/account/tests/account_test_classes.py
Expand Up @@ -5,6 +5,7 @@

from odoo.tests.common import HttpCase, tagged
from odoo.exceptions import ValidationError
from odoo.tools import float_is_zero


class AccountingTestCase(HttpCase):
Expand All @@ -21,15 +22,73 @@ def setUp(self):
_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):
Expand Down

0 comments on commit d49ab56

Please sign in to comment.