diff --git a/addons/account/__init__.py b/addons/account/__init__.py index 1c47b94f829..b68cc24e7db 100644 --- a/addons/account/__init__.py +++ b/addons/account/__init__.py @@ -45,6 +45,8 @@ def _auto_install_l10n(cr, registry): 'PL', 'PT', 'RO', 'SI', 'TR', 'UK', 'VE', 'VN' ]: module_list.append('base_vat') + if country_code == 'MX': + module_list.append('l10n_mx_edi') # European countries will be using SEPA europe = env.ref('base.europe', raise_if_not_found=False) diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index 3667a01f966..9d042afbd3c 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -28,6 +28,7 @@ You could use this simplified accounting in case you work with an (external) acc 'wizard/account_unreconcile_view.xml', 'wizard/account_move_reversal_view.xml', 'views/account_move_views.xml', + 'wizard/setup_wizards_view.xml', 'views/account_view.xml', 'views/account_report.xml', 'data/mail_template_data.xml', @@ -54,14 +55,14 @@ You could use this simplified accounting in case you work with an (external) acc 'views/account_portal_templates.xml', 'views/report_payment_receipt_templates.xml', 'data/payment_receipt_data.xml', - 'wizard/setup_wizards_view.xml', 'views/account_onboarding_templates.xml', - 'data/service_cron_reverse.xml', + 'data/service_cron.xml', 'views/account_fiscal_year_view.xml', 'views/account_incoterms_view.xml', 'data/account_incoterms_data.xml', 'views/digest_views.xml', 'wizard/account_invoice_send_views.xml', + 'views/account_tax_report_views.xml', ], 'demo': [ 'demo/account_demo.xml', @@ -71,6 +72,7 @@ You could use this simplified accounting in case you work with an (external) acc "static/src/xml/account_payment.xml", "static/src/xml/account_report_backend.xml", "static/src/xml/bills_tree_upload_views.xml", + 'static/src/xml/account_journal_activity.xml', ], 'installable': True, 'application': True, diff --git a/addons/account/data/service_cron.xml b/addons/account/data/service_cron.xml new file mode 100644 index 00000000000..6436692b2d5 --- /dev/null +++ b/addons/account/data/service_cron.xml @@ -0,0 +1,14 @@ + + + + Account; Post draft entries with auto_post set to True up to today + 1 + days + -1 + + + + model._run_post_draft_to_post() + code + + diff --git a/addons/account/data/service_cron_reverse.xml b/addons/account/data/service_cron_reverse.xml deleted file mode 100644 index 982abbeea1f..00000000000 --- a/addons/account/data/service_cron_reverse.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - Account; Reverse entries - 1 - days - -1 - - - model._run_reverses_entries() - code - - diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 3453e39a2cf..09ca1374b32 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -5,7 +5,7 @@ import math import re from odoo.osv import expression -from odoo.tools.float_utils import float_round as round +from odoo.tools.float_utils import float_round as round, float_compare from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT from odoo.exceptions import UserError, ValidationError from odoo import api, fields, models, _ @@ -45,6 +45,123 @@ class AccountAccountTag(models.Model): applicability = fields.Selection([('accounts', 'Accounts'), ('taxes', 'Taxes')], required=True, default='accounts') color = fields.Integer('Color Index') active = fields.Boolean(default=True, help="Set active to false to hide the Account Tag without removing it.") + tax_report_line_ids = fields.Many2many(string="Tax Report Lines", comodel_name='account.tax.report.line', relation='account_tax_report_line_tags_rel', help="The tax report lines using this tag") + tax_negate = fields.Boolean(string="Negate Tax Balance", help="Check this boox to negate the absolute value of the balance of the lines associated with this tag in tax report computation.") + country_id = fields.Many2one(string="Country", comodel_name='res.country', help="Country for which this tag is available, when applied on taxes.") + + +class AccountTaxReportLine(models.Model): + _name = "account.tax.report.line" + _description = 'Account Tax Report Line' + _order = 'sequence' + _parent_store = True + + name = fields.Char(string="Name", required=True, help="Complete name for this report line, to be used in report.") + tag_ids = fields.Many2many(string="Tags", comodel_name='account.account.tag', relation='account_tax_report_line_tags_rel', help="Tax tags populating this line") + country_id = fields.Many2one(string="Country", comodel_name='res.country', required=True, default=lambda x: x.env.user.company_id.country_id.id, help="Country for which this line is available.") + report_action_id = fields.Many2one(string="Report Action", comodel_name='ir.actions.act_window', help="The optional action to call when clicking on this line in accounting reports.") + children_line_ids = fields.One2many(string="Children Lines", comodel_name='account.tax.report.line', inverse_name='parent_id', help="Lines that should be rendered as children of this one") + parent_id = fields.Many2one(string="Parent Line", comodel_name='account.tax.report.line') + sequence = fields.Integer(string='Sequence', required=True, help="Sequence determining the order of the lines in the report (smaller ones come first). This order is applied locally per section (so, chilldren of the same line are always rendered one after the other).") + parent_path = fields.Char(index=True) + + #helper to create tags (positive and negative) on report line creation + tag_name = fields.Char(string="Tag Name", help="Short name for the tax grid corresponding to this report line. Leave empty if this report line should not correspond to any such grid.") + + #fields used in specific localization reports, where a report line isn't simply the given by the sum of account.move.line with selected tags + code = fields.Char(string="Code", help="Optional unique code to refer to this line in total formulas") + formula = fields.Char(string="Formula", help="Python expression used to compute the value of a total line. This field is mutually exclusive with tag_name, setting it turns the line to a total line. Tax report line codes can be used as variables in this expression to refer to the balance of the corresponding lines in the report. A formula cannot refer to another line using a formula.") + + @api.model + def create(self, vals): + tag_name = vals.get('tag_name', '') + if tag_name: + vals['tag_ids'] = self._get_tags_create_vals(tag_name, vals.get('country_id')) + return super(AccountTaxReportLine, self).create(vals) + + @api.model + def _get_tags_create_vals(self, tag_name, country_id): + minus_tag_vals = { + 'name': '-' + tag_name, + 'applicability': 'taxes', + 'tax_negate': True, + 'country_id': country_id, + } + plus_tag_vals = { + 'name': '+' + tag_name, + 'applicability': 'taxes', + 'tax_negate': False, + 'country_id': country_id, + } + return [(0, 0, minus_tag_vals), (0, 0, plus_tag_vals)] + + def write(self, vals): + rslt = super(AccountTaxReportLine, self).write(vals) + if 'tag_name' in vals: + # Constraint _validate_tags ensures that tag_ids always contains two + # tags if not empty: + and -. + # So, we can impact the whole tag_ids set if tag_name is modified. + if vals['tag_name']: + # If some lines in the set did not have tags yet, we create them + without_tags = self.filtered(lambda x: not x.tag_ids) + for record in without_tags: + record.write({'tag_ids': self._get_tags_create_vals(record.tag_name, record.country_id.id)}) + + # We update the name of every tag linked to other lines + to_update = self - without_tags + tags_to_update = to_update.mapped('tag_ids') + minus_child_tags = tags_to_update.filtered(lambda x: x.tax_negate) + minus_child_tags.write({'name': '-' + vals['tag_name']}) + + plus_child_tags = tags_to_update.filtered(lambda x: not x.tax_negate) + plus_child_tags.write({'name': '+' + vals['tag_name']}) + + else: + # tag_name was set empty, so we remove the tags + self._delete_tags_from_taxes(self.mapped('tag_ids.id')) + self.write({'tag_ids': [(2, tag.id, 0) for tag in self.mapped('tag_ids')]}) + + return rslt + + def unlink(self): + self._delete_tags_from_taxes(self.mapped('tag_ids.id')) + children = self.mapped('children_line_ids') + if children: + children.unlink() + return super(AccountTaxReportLine, self).unlink() + + def _delete_tags_from_taxes(self, tag_ids_to_delete): + """ Based on a list of tag ids, delete them all from database, removing them + first from the repartition lines they are linked to. + """ + repartition_lines = self.env['account.tax.repartition.line'].search([('tag_ids', 'in', tag_ids_to_delete)]) + repartition_lines.write({'tag_ids': [(3, tag_id, 0) for tag_id in tag_ids_to_delete]}) + + @api.constrains('formula', 'tag_name') + def _validate_formula(self): + if self.formula and self.tag_name: + raise ValidationError(_("Tag name and formula are mutually exclusive, they should not be set together on the same tax report line.")) + + @api.constrains('tag_name', 'tag_ids') + def _validate_tags(self): + neg_tags = self.tag_ids.filtered(lambda x: x.tax_negate) + pos_tags = self.tag_ids.filtered(lambda x: not x.tax_negate) + if self.tag_ids and (len(neg_tags) !=1 or len(pos_tags) != 1): + raise ValidationError(_("If tags are defined for a tax report line, only two are allowed on it: a positive and a negative one.")) + + @api.onchange('parent_id') + def _onchange_parent_id(self): + """ If the parent of a report line is changed, the sequence of this line + must be updated, in order for it to match the sequence numbering of its + parent. By default, we add it as the last line of this section. + """ + if self.country_id: + lines_domain = [('country_id', '=', self.country_id.id)] + if self.parent_id: + lines_domain.append(('parent_id', '=', self.parent_id.id)) + + last_line = self.env['account.tax.report.line'].search(lines_domain, order="sequence desc", limit=1) + self.sequence = last_line.sequence + 1 #---------------------------------------------------------- # Accounts @@ -88,7 +205,7 @@ class AccountAccount(models.Model): reconcile = fields.Boolean(string='Allow Reconciliation', default=False, help="Check this box if this account allows invoices & payments matching of journal items.") tax_ids = fields.Many2many('account.tax', 'account_account_tax_default_rel', - 'account_id', 'tax_id', string='Default Taxes') + 'account_id', 'tax_id', string='Default Taxes', context={'append_type_to_tax_name': True}) note = fields.Text('Internal Notes') company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company_id) @@ -883,6 +1000,10 @@ class AccountTaxGroup(models.Model): name = fields.Char(required=True, translate=True) sequence = fields.Integer(default=10) + property_tax_payable_account_id = fields.Many2one('account.account', company_dependent=True, string='Tax current account (payable)') + property_tax_receivable_account_id = fields.Many2one('account.account', company_dependent=True, string='Tax current account (receivable)') + property_advance_tax_payment_account_id = fields.Many2one('account.account', company_dependent=True, string='Advance Tax payment account') + class AccountTax(models.Model): _name = 'account.tax' @@ -897,24 +1018,29 @@ class AccountTax(models.Model): type_tax_use = fields.Selection([('sale', 'Sales'), ('purchase', 'Purchases'), ('none', 'None'), ('adjustment', 'Adjustment')], string='Tax Scope', required=True, default="sale", help="Determines where the tax is selectable. Note : 'None' means a tax can't be used by itself, however it can still be used in a group. 'adjustment' is used to perform tax adjustment.") amount_type = fields.Selection(default='percent', string="Tax Computation", required=True, oldname='type', - selection=[('group', 'Group of Taxes'), ('fixed', 'Fixed'), ('percent', 'Percentage of Price'), ('division', 'Percentage of Price Tax Included')]) + selection=[('group', 'Group of Taxes'), ('fixed', 'Fixed'), ('percent', 'Percentage of Price'), ('division', 'Percentage of Price Tax Included')], + help=""" + - Group of Taxes: The tax is a set of sub taxes. + - Fixed: The tax amount stays the same whatever the price. + - Percentage of Price: The tax amount is a % of the price: + e.g 100 * 10% = 110 (not price included) + e.g 110 / (1 + 10%) = 100 (price included) + - Percentage of Price Tax Included: The tax amount is a division of the price: + e.g 180 / (1 - 10%) = 200 (not price included) + e.g 200 * (1 - 10%) = 180 (price included) + """) active = fields.Boolean(default=True, help="Set active to false to hide the tax without removing it.") company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company_id) children_tax_ids = fields.Many2many('account.tax', 'account_tax_filiation_rel', 'parent_tax', 'child_tax', string='Children Taxes') sequence = fields.Integer(required=True, default=1, help="The sequence field is used to define order in which the tax lines are applied.") amount = fields.Float(required=True, digits=(16, 4)) - account_id = fields.Many2one('account.account', domain=[('deprecated', '=', False)], string='Tax Account', ondelete='restrict', - help="Account that will be set on invoice tax lines for invoices. Leave empty to use the expense account.", oldname='account_collected_id') - refund_account_id = fields.Many2one('account.account', domain=[('deprecated', '=', False)], string='Tax Account on Credit Notes', ondelete='restrict', - help="Account that will be set on invoice tax lines for credit notes. Leave empty to use the expense account.", oldname='account_paid_id') description = fields.Char(string='Label on Invoices') price_include = fields.Boolean(string='Included in Price', default=False, help="Check this if the price you use on the product and invoices includes this tax.") include_base_amount = fields.Boolean(string='Affect Base of Subsequent Taxes', default=False, help="If set, taxes which are computed after this one will be computed based on the price tax included.") analytic = fields.Boolean(string="Include in Analytic Cost", help="If set, the amount computed by this tax will be assigned to the same analytic account as the invoice line (if any)") - tag_ids = fields.Many2many('account.account.tag', 'account_tax_account_tag', string='Tags', help="Optional tags you may want to assign for custom reporting") tax_group_id = fields.Many2one('account.tax.group', string="Tax Group", default=_default_tax_group, required=True) # Technical field to make the 'tax_exigibility' field invisible if the same named field is set to false in 'res.company' model hide_tax_exigibility = fields.Boolean(string='Hide Use Cash Basis Option', related='company_id.tax_exigibility', readonly=True) @@ -925,22 +1051,55 @@ class AccountTax(models.Model): oldname='use_cash_basis', help="Based on Invoice: the tax is due as soon as the invoice is validated.\n" "Based on Payment: the tax is due as soon as the payment of the invoice is received.") - cash_basis_account_id = fields.Many2one( - 'account.account', - string='Tax Received Account', - domain=[('deprecated', '=', False)], - oldname='cash_basis_account', - help='Account used as counterpart for the journal entry, for taxes eligible based on payments.') + cash_basis_transition_account_id = fields.Many2one(string="Cash Basis Transition Account", domain=[('deprecated', '=', False)], comodel_name='account.account', help="Account used to transition the tax amount for cash basis taxes. It will contain the tax amount as long as the original invoice has not been reconciled ; at reconciliation, this amount cancelled on this account and put on the regular tax account.") cash_basis_base_account_id = fields.Many2one( '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.') + invoice_repartition_line_ids = fields.One2many(string="Repartition for Invoices", comodel_name="account.tax.repartition.line", inverse_name="invoice_tax_id", copy=True, help="Repartition when the tax is used on an invoice") + refund_repartition_line_ids = fields.One2many(string="Repartition for Refund Invoices", comodel_name="account.tax.repartition.line", inverse_name="refund_tax_id", copy=True, help="Repartition when the tax is used on a refund") + country_id = fields.Many2one(string='Country', comodel_name='res.country', related='company_id.country_id', help="Technical field used to restrict the domain of account tags for tax repartition lines created for this tax.") _sql_constraints = [ ('name_company_uniq', 'unique(name, company_id, type_tax_use)', 'Tax names must be unique !'), ] + @api.model + def default_get(self, vals): + rslt = super(AccountTax, self).default_get(vals) + + if 'refund_repartition_line_ids' in vals: + rslt['refund_repartition_line_ids'] = [ + (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': rslt.get('company_id')}), + (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': rslt.get('company_id')}), + ] + + if 'invoice_repartition_line_ids' in vals: + rslt['invoice_repartition_line_ids'] = [ + (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': rslt.get('company_id')}), + (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': rslt.get('company_id')}), + ] + + return rslt + + def _check_repartition_lines(self, lines): + self.ensure_one() + + base_line = lines.filtered(lambda x: x.repartition_type == 'base') + if len(base_line) != 1: + raise ValidationError(_("Invoice and credit note repartition should each contain exactly one line for the base.")) + + @api.constrains('invoice_repartition_line_ids', 'refund_repartition_line_ids') + def _validate_repartition_lines(self): + for record in self: + record._check_repartition_lines(record.invoice_repartition_line_ids) + record._check_repartition_lines(record.refund_repartition_line_ids) + sum_inv_tax = sum(record.mapped('invoice_repartition_line_ids.factor_percent')) + sum_refund_tax = sum(record.mapped('refund_repartition_line_ids.factor_percent')) + if float_compare(sum_inv_tax, sum_refund_tax, precision_digits=4) != 0: + raise ValidationError(_("You should impact the same total percentage of the tax amount for invoices and refunds.")) + @api.one @api.constrains('children_tax_ids', 'type_tax_use') def _check_children_scope(self): @@ -1002,10 +1161,6 @@ class AccountTax(models.Model): if self.amount_type is not 'group': self.children_tax_ids = [(5,)] - @api.onchange('account_id') - def onchange_account_id(self): - self.refund_account_id = self.account_id - @api.onchange('price_include') def onchange_price_include(self): if self.price_include: @@ -1015,15 +1170,18 @@ class AccountTax(models.Model): """ Returns a string that will be used to group account.invoice.tax sharing the same properties""" self.ensure_one() return str(invoice_tax_val['tax_id']) + '-' + \ + str(invoice_tax_val.get('tax_repartition_line_id')) + '-' + \ str(invoice_tax_val['account_id']) + '-' + \ str(invoice_tax_val['account_analytic_id']) + '-' + \ - str(invoice_tax_val.get('analytic_tag_ids', [])) + str(invoice_tax_val.get('analytic_tag_ids', [])) + '-' + \ + str(invoice_tax_val.get('tax_ids') or []) def _compute_amount(self, base_amount, price_unit, quantity=1.0, product=None, partner=None): """ Returns the amount of a single tax. base_amount is the actual amount on which the tax is applied, which is price_unit * quantity eventually affected by previous taxes (if tax is include_base_amount XOR price_include) """ self.ensure_one() + if self.amount_type == 'fixed': # Use copysign to take into account the sign of the base amount which includes the sign # of the quantity and the sign of the price_unit @@ -1038,17 +1196,23 @@ class AccountTax(models.Model): else: return quantity * self.amount - price_include = self.price_include or self._context.get('force_price_include') + price_include = self._context['force_price_include'] if 'force_price_include' in self._context else self.price_include - if (self.amount_type == 'percent' and not price_include) or (self.amount_type == 'division' and price_include): + # base * (1 + tax_amount) = new_base + if self.amount_type == 'percent' and not price_include: return base_amount * self.amount / 100 + # <=> new_base = base / (1 + tax_amount) if self.amount_type == 'percent' and price_include: return base_amount - (base_amount / (1 + self.amount / 100)) + # base / (1 - tax_amount) = new_base if self.amount_type == 'division' and not price_include: return base_amount / (1 - self.amount / 100) - base_amount + # <=> new_base * (1 - tax_amount) = base + if self.amount_type == 'division' and price_include: + return base_amount - (base_amount * (self.amount / 100)) @api.multi - def json_friendly_compute_all(self, price_unit, currency_id=None, quantity=1.0, product_id=None, partner_id=None): + def json_friendly_compute_all(self, price_unit, currency_id=None, quantity=1.0, product_id=None, partner_id=None, is_refund=False): """ Just converts parameters in browse records and calls for compute_all, because js widgets can't serialize browse records """ if currency_id: currency_id = self.env['res.currency'].browse(currency_id) @@ -1056,10 +1220,23 @@ class AccountTax(models.Model): product_id = self.env['product.product'].browse(product_id) if partner_id: partner_id = self.env['res.partner'].browse(partner_id) - return self.compute_all(price_unit, currency=currency_id, quantity=quantity, product=product_id, partner=partner_id) + return self.compute_all(price_unit, currency=currency_id, quantity=quantity, product=product_id, partner=partner_id, is_refund=is_refund) + + def flatten_taxes_hierarchy(self): + # Flattens the taxes contained in this recordset, returning all the + # children at the bottom of the hierarchy, in a recordset, ordered by sequence. + # Eg. considering letters as taxes and alphabetic order as sequence : + # [G, B([A, D, F]), E, C] will be computed as [A, D, F, C, E, G] + all_taxes = self.env['account.tax'] + for tax in self.sorted(key=lambda r: r.sequence): + if tax.amount_type == 'group': + all_taxes += tax.children_tax_ids.flatten_taxes_hierarchy() + else: + all_taxes += tax + return all_taxes @api.multi - def compute_all(self, price_unit, currency=None, quantity=1.0, product=None, partner=None): + def compute_all(self, price_unit, currency=None, quantity=1.0, product=None, partner=None, is_refund=False): """ Returns all information required to apply taxes (in self + their children in case of a tax group). We consider the sequence of the parent for group of taxes. Eg. considering letters as taxes and alphabetic order as sequence : @@ -1077,15 +1254,32 @@ class AccountTax(models.Model): 'account_id': int, 'refund_account_id': int, 'analytic': boolean, - }] + }], } """ - if len(self) == 0: - company_id = self.env.company_id + if not self: + company = self.env.company_id else: - company_id = self[0].company_id + company = self[0].company_id + + # 1) Flatten the taxes. + taxes = self.flatten_taxes_hierarchy() + + # 2) Avoid mixing taxes having price_include=False && include_base_amount=True + # with taxes having price_include=True. This use case is not supported as the + # computation of the total_excluded would be impossible. + base_excluded_flag = False # price_include=False && include_base_amount=True + included_flag = False # price_include=True + for tax in taxes: + if tax.price_include: + included_flag = True + elif tax.include_base_amount: + base_excluded_flag = True + if base_excluded_flag and included_flag: + raise UserError(_('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.')) + + # 3) Deal with the rounding methods if not currency: - currency = company_id.currency_id - taxes = [] + currency = company.currency_id # By default, for each tax, tax amount will first be computed # and rounded at the 'Account' decimal precision for each # PO/SO/invoice line and then these rounded amounts will be @@ -1101,7 +1295,7 @@ class AccountTax(models.Model): # amounts. For example, in SO/PO line, we don't want to round the price unit at the # precision of the currency. # The context key 'round' allows to force the standard behavior. - round_tax = False if company_id.tax_calculation_rounding_method == 'round_globally' else True + round_tax = False if company.tax_calculation_rounding_method == 'round_globally' else True round_total = True if 'round' in self.env.context: round_tax = bool(self.env.context['round']) @@ -1110,73 +1304,132 @@ class AccountTax(models.Model): if not round_tax: prec += 5 - base_values = self.env.context.get('base_values') - if not base_values: - total_excluded = total_included = total_void = base = round(price_unit * quantity, prec) - else: - total_excluded, total_included, total_void, base = base_values + # 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + # tax | base | amount | + # /\ ---------------------------- + # || tax_1 | XXXX | | <- we are looking for that, it's the total_excluded + # || tax_2 | .. | | + # || tax_3 | .. | | + # || ... | .. | .. | + # ---------------------------- + def recompute_base(base_amount, fixed_amount, percent_amount, division_amount): + # Recompute the new base amount based on included fixed/percent amounts and the current base amount. + # Example: + # tax | amount | type | price_include | + # ----------------------------------------------- + # tax_1 | 10% | percent | t + # tax_2 | 15 | fix | t + # tax_3 | 20% | percent | t + # tax_4 | 10% | division | t + # ----------------------------------------------- - # Sorting key is mandatory in this case. When no key is provided, sorted() will perform a - # search. However, the search method is overridden in account.tax in order to add a domain - # depending on the context. This domain might filter out some taxes from self, e.g. in the - # case of group taxes. - for tax in self.sorted(key=lambda r: r.sequence): - # Allow forcing price_include/include_base_amount through the context for the reconciliation widget. - # See task 24014. - price_include = self._context.get('force_price_include', tax.price_include) + # if base_amount = 145, the new base is computed as: + # (145 - 15) / (1.0 + 30%) * 90% = 130 / 1.3 * 90% = 90 + return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) * (100 - division_amount) / 100 - if tax.amount_type == 'group': - children = tax.children_tax_ids.with_context(base_values=(total_excluded, total_included, total_void, base)) - ret = children.compute_all(price_unit, currency, quantity, product, partner) - total_excluded = ret['total_excluded'] - base = ret['base'] if tax.include_base_amount else base - total_included = ret['total_included'] - total_void = ret['total_void'] - tax_amount = total_included - total_excluded - taxes += ret['taxes'] - continue + base = round(price_unit * quantity, prec) - tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) - if not round_tax: - tax_amount = round(tax_amount, prec) + # For the computation of move lines, we could have a negative base value. + # In this case, compute all with positive values and negate them at the end. + sign = 1 + if base < 0: + base = -base + sign = -1 + + # Store the totals to reach when using price_include taxes (only the last price included in row) + total_included_checkpoints = {} + i = len(taxes) - 1 + store_included_tax_total = True + # Keep track of the accumulated included fixed/percent amount. + incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 + # Store the tax amounts we compute while searching for the total_excluded + cached_tax_amounts = {} + for tax in reversed(taxes): + if tax.include_base_amount: + base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount) + incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 + store_included_tax_total = True + if tax.price_include: + if tax.amount_type == 'percent': + incl_percent_amount += tax.amount + elif tax.amount_type == 'division': + incl_division_amount += tax.amount + elif tax.amount_type == 'fixed': + incl_fixed_amount += quantity * tax.amount + else: + # tax.amount_type == other (python) + tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + incl_fixed_amount += tax_amount + # Avoid unecessary re-computation + cached_tax_amounts[i] = tax_amount + if store_included_tax_total: + total_included_checkpoints[i] = base + store_included_tax_total = False + i -= 1 + + total_excluded = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount) + + # 5) Iterate the taxes in the sequence order to compute missing tax amounts. + # Start the computation of accumulated amounts at the total_excluded value. + base = total_included = total_void = total_excluded + + taxes_vals = [] + i = 0 + cumulated_tax_included_amount = 0 + for tax in taxes: + #compute the tax_amount + if tax.price_include and total_included_checkpoints.get(i): + # We know the total to reach for that tax, so we make a substraction to avoid any rounding issues + tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount) + cumulated_tax_included_amount = 0 else: - tax_amount = currency.round(tax_amount) + tax_amount = tax.with_context(force_price_include=False)._compute_amount( + base, price_unit, quantity, product, partner) - if price_include: - total_excluded -= tax_amount - base -= tax_amount - else: - total_included += tax_amount - # The total_void amount is computed as the sum of total_excluded - # with all tax_amount, where tax has an account set - if not tax.account_id: - total_void += tax_amount + # Round the tax_amount + tax_amount = round(tax_amount, prec) - # Keep base amount used for the current tax - tax_base = base + if tax.price_include and not total_included_checkpoints.get(i): + cumulated_tax_included_amount += tax_amount + tax_repartition_lines = is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids + repartition_lines_to_treat = len(tax_repartition_lines) + total_amount = 0 + for repartition_line in tax_repartition_lines.filtered(lambda x: x.repartition_type == 'tax'): + # In case some rounding error occurs, we compensate for it on the last line + line_amount = round(sign * tax_amount * repartition_line.factor if repartition_lines_to_treat != 1 else tax_amount - total_amount, prec) + taxes_vals.append({ + 'id': tax.id, + 'name': partner and tax.with_context(lang=partner.lang).name or tax.name, + 'amount': line_amount, + 'base': round(sign * base, prec), + 'sequence': tax.sequence, + 'account_id': tax.cash_basis_transition_account_id.id if tax.tax_exigibility == 'on_payment' else repartition_line.account_id.id, + 'analytic': tax.analytic, + 'price_include': tax.price_include, + 'tax_exigibility': tax.tax_exigibility, + 'tax_repartition_line_id': repartition_line.id, + 'tag_ids': [(6, False, repartition_line.tag_ids.ids)], + }) + + total_amount += line_amount + if not repartition_line.account_id: + total_void += line_amount + repartition_lines_to_treat -= 1 + + # Affect subsequent taxes if tax.include_base_amount: base += tax_amount - taxes.append({ - 'id': tax.id, - 'name': tax.with_context(**{'lang': partner.lang} if partner else {}).name, - 'amount': tax_amount, - 'base': tax_base, - 'sequence': tax.sequence, - 'account_id': tax.account_id.id, - 'refund_account_id': tax.refund_account_id.id, - 'analytic': tax.analytic, - 'price_include': tax.price_include, - 'tax_exigibility': tax.tax_exigibility, - }) + total_included += tax_amount + i += 1 return { - 'taxes': sorted(taxes, key=lambda k: k['sequence']), - 'total_excluded': currency.round(total_excluded) if round_total else total_excluded, - 'total_included': currency.round(total_included) if round_total else total_included, - 'total_void': currency.round(total_void) if round_total else total_void, - 'base': base, + 'base_tags': taxes.mapped(is_refund and 'refund_repartition_line_ids' or 'invoice_repartition_line_ids').filtered(lambda x: x.repartition_type == 'base').mapped('tag_ids').ids, + 'taxes': taxes_vals, + 'total_excluded': sign * (currency.round(total_excluded) if round_total else total_excluded), + 'total_included': sign * (currency.round(total_included) if round_total else total_included), + 'total_void': sign * (currency.round(total_void) if round_total else total_void), } @api.model @@ -1195,3 +1448,42 @@ class AccountTax(models.Model): prod_taxes = prod_taxes.filtered(lambda tax: tax.company_id == company_id) line_taxes = line_taxes.filtered(lambda tax: tax.company_id == company_id) return self._fix_tax_included_price(price, prod_taxes, line_taxes) + + +class AccountTaxRepartitionLine(models.Model): + _name = "account.tax.repartition.line" + _description = "Tax Repartition Line" + _order = 'sequence, repartition_type, id' + + factor_percent = fields.Float(string="%", required=True, help="Factor to apply on the account move lines generated from this repartition line, in percents") + factor = fields.Float(string="Factor Ratio", compute="_compute_factor", help="Factor to apply on the account move lines generated from this repartition line") + repartition_type = fields.Selection(string="Based On", selection=[('base', 'Base'), ('tax', 'of tax')], required=True, default='tax', help="Base on which the factor will be applied.") + account_id = fields.Many2one(string="Account", comodel_name='account.account', help="Account on which to post the tax amount") + tag_ids = fields.Many2many(string="Tax Grid", comodel_name='account.account.tag', domain=[('applicability', '=', 'taxes')], copy=True) + invoice_tax_id = fields.Many2one(comodel_name='account.tax', help="The tax set to apply this repartition on invoices. Mutually exclusive with refund_tax_id") + refund_tax_id = fields.Many2one(comodel_name='account.tax', help="The tax set to apply this repartition on refund invoices. Mutually exclusive with invoice_tax_id") + tax_id = fields.Many2one(comodel_name='account.tax', compute='_compute_tax_id') + country_id = fields.Many2one(string="Country", comodel_name='res.country', related='company_id.country_id', help="Technical field used to restrict tags domain in form view.") + company_id = fields.Many2one(string="Company", comodel_name='res.company', required=True, default=lambda x: x.env.user.company_id, help="The company this repartition line belongs to.") + sequence = fields.Integer(string="Sequence", default=1, help="The order in which display and match repartition lines. For refunds to work properly, invoice repartition lines should be arranged in the same order as the credit note repartition lines they correspond to.") + + @api.constrains('invoice_tax_id', 'refund_tax_id') + def validate_tax_template_link(self): + for record in self: + if record.invoice_tax_id and record.refund_tax_id: + raise ValidationError(_("Tax repartition lines should apply to either invoices or refunds, not both at the same time. invoice_tax_id and refund_tax_id should not be set together.")) + + @api.depends('factor_percent') + def _compute_factor(self): + for record in self: + record.factor = record.factor_percent / 100.0 + + @api.depends('invoice_tax_id', 'refund_tax_id') + def _compute_tax_id(self): + for record in self: + record.tax_id = record.invoice_tax_id or record.refund_tax_id + + @api.onchange('repartition_type') + def _onchange_repartition_type(self): + if self.repartition_type == 'base': + self.account_id = None diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 631a053b905..c1cfa7aef0b 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -446,6 +446,7 @@ class AccountBankStatementLine(models.Model): """ Prepare the dict of values to balance the move. :param recordset move: the account.move to link the move line + :param dict move: a dict of vals of a account.move which will be created later :param float amount: the amount of transaction that wasn't already reconciled """ company_currency = self.journal_id.company_id.currency_id @@ -453,19 +454,23 @@ class AccountBankStatementLine(models.Model): st_line_currency = self.currency_id or statement_currency amount_currency = False st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False + if isinstance(move, dict): + amount_sum = sum(x[2].get('amount_currency', 0) for x in move['line_ids']) + else: + amount_sum = sum(x.amount_currency for x in move.line_ids) # We have several use case here to compare the currency and amount currency of counterpart line to balance the move: if st_line_currency != company_currency and st_line_currency == statement_currency: # company in currency A, statement in currency B and transaction in currency B # counterpart line must have currency B and correct amount is inverse of already existing lines - amount_currency = -sum([x.amount_currency for x in move.line_ids]) + amount_currency = -amount_sum elif st_line_currency != company_currency and statement_currency == company_currency: # company in currency A, statement in currency A and transaction in currency B # counterpart line must have currency B and correct amount is inverse of already existing lines - amount_currency = -sum([x.amount_currency for x in move.line_ids]) + amount_currency = -amount_sum elif st_line_currency != company_currency and st_line_currency != statement_currency: # company in currency A, statement in currency B and transaction in currency C # counterpart line must have currency B and use rate between B and C to compute correct amount - amount_currency = -sum([x.amount_currency for x in move.line_ids])/st_line_currency_rate + amount_currency = -amount_sum/st_line_currency_rate elif st_line_currency == company_currency and statement_currency != company_currency: # company in currency A, statement in currency B and transaction in currency A # counterpart line must have currency B and amount is computed using the rate between A and B @@ -474,9 +479,8 @@ class AccountBankStatementLine(models.Model): # last case is company in currency A, statement in currency A and transaction in currency A # and in this case counterpart line does not need any second currency nor amount_currency - return { + aml_dict = { 'name': self.name, - 'move_id': move.id, 'partner_id': self.partner_id and self.partner_id.id or False, 'account_id': amount >= 0 \ and self.statement_id.journal_id.default_credit_account_id.id \ @@ -487,22 +491,71 @@ class AccountBankStatementLine(models.Model): 'currency_id': statement_currency != company_currency and statement_currency.id or (st_line_currency != company_currency and st_line_currency.id or False), 'amount_currency': amount_currency, } + if isinstance(move, self.env['account.move'].__class__): + aml_dict['move_id'] = move.id + return aml_dict + @api.multi def fast_counterpart_creation(self): """This function is called when confirming a bank statement and will allow to automatically process lines without going in the bank reconciliation widget. By setting an account_id on bank statement lines, it will create a journal entry using that account to counterpart the bank account """ + payment_list = [] + move_list = [] + account_type_receivable = self.env.ref('account.data_account_type_receivable') + already_done_stmt_line_ids = [a['statement_line_id'][0] for a in self.env['account.move.line'].read_group([('statement_line_id', 'in', self.ids)], ['statement_line_id'], ['statement_line_id'])] + managed_st_line = [] for st_line in self: # Technical functionality to automatically reconcile by creating a new move line - if st_line.account_id and not st_line.journal_entry_ids.ids: - vals = { + if st_line.account_id and not st_line.id in already_done_stmt_line_ids: + managed_st_line.append(st_line.id) + # Create payment vals + total = st_line.amount + payment_methods = (total > 0) and st_line.journal_id.inbound_payment_method_ids or st_line.journal_id.outbound_payment_method_ids + currency = st_line.journal_id.currency_id or st_line.company_id.currency_id + partner_type = 'customer' if st_line.account_id.user_type_id == account_type_receivable else 'supplier' + payment_list.append({ + 'payment_method_id': payment_methods and payment_methods[0].id or False, + 'payment_type': total > 0 and 'inbound' or 'outbound', + 'partner_id': st_line.partner_id.id, + 'partner_type': partner_type, + 'journal_id': st_line.statement_id.journal_id.id, + 'payment_date': st_line.date, + 'state': 'reconciled', + 'currency_id': currency.id, + 'amount': abs(total), + 'communication': st_line._get_communication(payment_methods[0] if payment_methods else False), + 'name': st_line.statement_id.name or _("Bank Statement %s") % st_line.date, + }) + + # Create move and move line vals + move_vals = st_line._prepare_reconciliation_move(st_line.statement_id.name) + aml_dict = { 'name': st_line.name, 'debit': st_line.amount < 0 and -st_line.amount or 0.0, 'credit': st_line.amount > 0 and st_line.amount or 0.0, 'account_id': st_line.account_id.id, + 'partner_id': st_line.partner_id.id, + 'statement_line_id': st_line.id, } - st_line.process_reconciliation(new_aml_dicts=[vals]) + st_line._prepare_move_line_for_currency(aml_dict, st_line.date or fields.Date.context_today()) + move_vals['line_ids'] = [(0, 0, aml_dict)] + balance_line = self._prepare_reconciliation_move_line(move_vals, st_line.amount) + move_vals['line_ids'].append((0, 0, balance_line)) + move_list.append(move_vals) + + # Creates + payment_ids = self.env['account.payment'].create(payment_list) + for payment_id, move_vals in zip(payment_ids, move_list): + for line in move_vals['line_ids']: + line[2]['payment_id'] = payment_id.id + move_ids = self.env['account.move'].create(move_list) + move_ids.post() + + for move, st_line, payment in zip(move_ids, self.browse(managed_st_line), payment_ids): + st_line.write({'move_name': move.name}) + payment.write({'payment_reference': move.name}) def _get_communication(self, payment_method_id): return self.name or '' @@ -630,8 +683,6 @@ class AccountBankStatementLine(models.Model): # Create move line(s). Either matching an existing journal entry (eg. invoice), in which # case we reconcile the existing and the new move lines together, or being a write-off. if counterpart_aml_dicts or new_aml_dicts: - st_line_currency = self.currency_id or statement_currency - st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False # Create the move self.sequence = self.statement_id.line_ids.ids.index(self.id) + 1 @@ -654,32 +705,12 @@ class AccountBankStatementLine(models.Model): # Complete dicts to create both counterpart move lines and write-offs to_create = (counterpart_aml_dicts + new_aml_dicts) - company = self.company_id date = self.date or fields.Date.today() for aml_dict in to_create: aml_dict['move_id'] = move.id aml_dict['partner_id'] = self.partner_id.id aml_dict['statement_line_id'] = self.id - if st_line_currency.id != company_currency.id: - aml_dict['amount_currency'] = aml_dict['debit'] - aml_dict['credit'] - aml_dict['currency_id'] = st_line_currency.id - if self.currency_id and statement_currency.id == company_currency.id and st_line_currency_rate: - # Statement is in company currency but the transaction is in foreign currency - aml_dict['debit'] = company_currency.round(aml_dict['debit'] / st_line_currency_rate) - aml_dict['credit'] = company_currency.round(aml_dict['credit'] / st_line_currency_rate) - elif self.currency_id and st_line_currency_rate: - # Statement is in foreign currency and the transaction is in another one - aml_dict['debit'] = statement_currency._convert(aml_dict['debit'] / st_line_currency_rate, company_currency, company, date) - aml_dict['credit'] = statement_currency._convert(aml_dict['credit'] / st_line_currency_rate, company_currency, company, date) - else: - # Statement is in foreign currency and no extra currency is given for the transaction - aml_dict['debit'] = st_line_currency._convert(aml_dict['debit'], company_currency, company, date) - aml_dict['credit'] = st_line_currency._convert(aml_dict['credit'], company_currency, company, date) - elif statement_currency.id != company_currency.id: - # Statement is in foreign currency but the transaction is in company currency - prorata_factor = (aml_dict['debit'] - aml_dict['credit']) / self.amount_currency - aml_dict['amount_currency'] = prorata_factor * self.amount - aml_dict['currency_id'] = statement_currency.id + self._prepare_move_line_for_currency(aml_dict, date) # Create write-offs for aml_dict in new_aml_dicts: @@ -729,6 +760,36 @@ class AccountBankStatementLine(models.Model): counterpart_moves.assert_balanced() return counterpart_moves + @api.multi + def _prepare_move_line_for_currency(self, aml_dict, date): + self.ensure_one() + company_currency = self.journal_id.company_id.currency_id + statement_currency = self.journal_id.currency_id or company_currency + st_line_currency = self.currency_id or statement_currency + st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False + company = self.company_id + + if st_line_currency.id != company_currency.id: + aml_dict['amount_currency'] = aml_dict['debit'] - aml_dict['credit'] + aml_dict['currency_id'] = st_line_currency.id + if self.currency_id and statement_currency.id == company_currency.id and st_line_currency_rate: + # Statement is in company currency but the transaction is in foreign currency + aml_dict['debit'] = company_currency.round(aml_dict['debit'] / st_line_currency_rate) + aml_dict['credit'] = company_currency.round(aml_dict['credit'] / st_line_currency_rate) + elif self.currency_id and st_line_currency_rate: + # Statement is in foreign currency and the transaction is in another one + aml_dict['debit'] = statement_currency._convert(aml_dict['debit'] / st_line_currency_rate, company_currency, company, date) + aml_dict['credit'] = statement_currency._convert(aml_dict['credit'] / st_line_currency_rate, company_currency, company, date) + else: + # Statement is in foreign currency and no extra currency is given for the transaction + aml_dict['debit'] = st_line_currency._convert(aml_dict['debit'], company_currency, company, date) + aml_dict['credit'] = st_line_currency._convert(aml_dict['credit'], company_currency, company, date) + elif statement_currency.id != company_currency.id: + # Statement is in foreign currency but the transaction is in company currency + prorata_factor = (aml_dict['debit'] - aml_dict['credit']) / self.amount_currency + aml_dict['amount_currency'] = prorata_factor * self.amount + aml_dict['currency_id'] = statement_currency.id + def _check_invoice_state(self, invoice): if invoice.state == 'in_payment' and all([payment.state == 'reconciled' for payment in invoice.mapped('payment_move_line_ids.payment_id')]): invoice.write({'state': 'paid'}) diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 8b1e00c86ca..3eabee15eab 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -70,6 +70,12 @@ class AccountInvoice(models.Model): self.amount_total_signed = self.amount_total * sign self.amount_untaxed_signed = amount_untaxed_signed * sign + def _compute_sign_taxes(self): + for invoice in self: + sign = invoice.type in ['in_refund', 'out_refund'] and -1 or 1 + invoice.amount_untaxed_invoice_signed = invoice.amount_untaxed * sign + invoice.amount_tax_signed = invoice.amount_tax * sign + @api.onchange('amount_total') def _onchange_amount_total(self): for inv in self: @@ -347,8 +353,12 @@ class AccountInvoice(models.Model): store=True, readonly=True, compute='_compute_amount', tracking=True) amount_untaxed_signed = fields.Monetary(string='Untaxed Amount in Company Currency', currency_field='company_currency_id', store=True, readonly=True, compute='_compute_amount') + amount_untaxed_invoice_signed = fields.Monetary(string='Untaxed Amount in Invoice Currency', currency_field='currency_id', + readonly=True, compute='_compute_sign_taxes') amount_tax = fields.Monetary(string='Tax', store=True, readonly=True, compute='_compute_amount') + amount_tax_signed = fields.Monetary(string='Tax in Invoice Currency', currency_field='currency_id', + readonly=True, compute='_compute_sign_taxes') amount_total = fields.Monetary(string='Total', store=True, readonly=True, compute='_compute_amount') amount_total_signed = fields.Monetary(string='Total in Invoice Currency', currency_field='currency_id', @@ -684,7 +694,7 @@ class AccountInvoice(models.Model): return self.env.ref('account.account_invoices_without_payment').report_action(self) @api.multi - def action_reconcile_to_check(self, params): + def action_reconcile_to_check(self): self.ensure_one() domain = self._get_domain_edition_mode_available() ids = self.env['account.move.line'].search(domain).mapped('statement_line_id').ids @@ -787,7 +797,7 @@ class AccountInvoice(models.Model): # Subscribe internal users on the newly created bill partners = self.env['res.partner'].browse(seen_partner_ids) is_internal = lambda p: (p.user_ids and - all(p.user_ids.mapped(lambda u: u.user_has_groups('base.group_user')))) + all(p.user_ids.mapped(lambda u: u.has_group('base.group_user')))) partners_to_subscribe = partners.filtered(is_internal) if partners_to_subscribe: invoice.message_subscribe([p.id for p in partners_to_subscribe]) @@ -852,6 +862,7 @@ class AccountInvoice(models.Model): taxes_grouped = self.get_taxes_values() tax_lines = self.tax_line_ids.filtered('manual') for tax in taxes_grouped.values(): + # ATTENTION: due to this, fields in tax have to be in the view (possibly invisible), as they won't be saved otherwise (when hitting "save") tax_lines += tax_lines.new(tax) self.tax_line_ids = tax_lines return @@ -1069,12 +1080,13 @@ class AccountInvoice(models.Model): else: return self.env.ref('account.invoice_form').id - def _prepare_tax_line_vals(self, line, tax): - """ Prepare values to create an account.invoice.tax line - - The line parameter is an account.invoice.line, and the - tax parameter is the output of account.tax.compute_all(). - """ + def _prepare_tax_line_vals(self, line, tax, tax_ids): + ''' Prepare values to create an account.invoice.tax line. + :param line: An account.invoice.line record. + :param tax: Tax values outputted by compute_all() in account.tax. + :param taxes: A list of account.tax ids affecting the tax base amount. + :return: The account.invoice.tax values to create a new record. + ''' vals = { 'invoice_id': self.id, 'name': tax['name'], @@ -1084,8 +1096,10 @@ class AccountInvoice(models.Model): 'manual': False, 'sequence': tax['sequence'], 'account_analytic_id': tax['analytic'] and line.account_analytic_id.id or False, - 'account_id': self.type in ('out_invoice', 'in_invoice') and (tax['account_id'] or line.account_id.id) or (tax['refund_account_id'] or line.account_id.id), + 'account_id': tax['account_id'] or line.account_id.id, 'analytic_tag_ids': tax['analytic'] and line.analytic_tag_ids.ids or False, + 'tax_ids': tax_ids and [(6, None, tax_ids)] or False, + 'tax_repartition_line_id': tax.get('tax_repartition_line_id'), # For base amount, we let this field empty } # If the taxes generate moves on the same financial account as the invoice line, @@ -1098,6 +1112,11 @@ class AccountInvoice(models.Model): @api.multi def get_taxes_values(self, tax_group_fields=False): + def is_tax_affecting_base_amount(tax): + return (tax.amount_type not in ('group', 'division') and tax.include_base_amount and tax.price_include)\ + or (tax.amount_type == 'division' and tax.include_base_amount and not tax.price_include) + # Avoid redundant browsing. + tax_map = dict((t.id, t) for t in self.invoice_line_ids.mapped('invoice_line_tax_ids')) default_tax_group_fields = set(['amount', 'base']) if tax_group_fields: default_tax_group_fields |= set(tax_group_fields) @@ -1106,18 +1125,33 @@ class AccountInvoice(models.Model): for line in self.invoice_line_ids: if not line.account_id: continue + price_unit = line.price_unit * (1 - (line.discount or 0.0) / 100.0) - taxes = line.invoice_line_tax_ids.compute_all(price_unit, self.currency_id, line.quantity, line.product_id, self.partner_id)['taxes'] - for tax in taxes: - val = self._prepare_tax_line_vals(line, tax) - key = self.env['account.tax'].browse(tax['id']).get_grouping_key(val) + taxes = line.invoice_line_tax_ids.compute_all(price_unit, self.currency_id, line.quantity, line.product_id, self.partner_id, is_refund=self.type in ('in_refund', 'out_refund'))['taxes'] + + affecting_base_tax_ids = [] + for tax_vals in taxes: + # Retrieve the tax record (not in tax_vals when dealing with group of taxes). + if tax_map.get(tax_vals['id']): + tax = tax_map[tax_vals['id']] + else: + tax = tax_map[tax_vals['id']] = self.env['account.tax'].browse(tax_vals['id']) + + val = self._prepare_tax_line_vals(line, tax_vals, affecting_base_tax_ids) + key = tax.get_grouping_key(val) if key not in tax_grouped: tax_grouped[key] = val tax_grouped[key]['base'] = round_curr(val['base']) else: for field in default_tax_group_fields: - tax_grouped[key][field] += round_curr(val.get(field) or 0) + tax_grouped[key][field] += round_curr(val.get(field)) or 0 + + if tax.amount_type == "group": + affecting_base_tax_ids += tax.children_tax_ids.filtered(lambda t: is_tax_affecting_base_amount(t)).ids + elif is_tax_affecting_base_amount(tax): + affecting_base_tax_ids.append(tax.id) + return tax_grouped @api.multi @@ -1199,13 +1233,19 @@ class AccountInvoice(models.Model): continue if line.quantity==0: continue - tax_ids = [] - for tax in line.invoice_line_tax_ids: - tax_ids.append((4, tax.id, None)) - for child in tax.children_tax_ids: - tax_ids.append((4, child.id, None)) + + taxes_to_flatten = line.invoice_line_tax_ids + flattened_taxes = self.env['account.tax'] + while taxes_to_flatten: + tax = taxes_to_flatten[0] + taxes_to_flatten -= tax + flattened_taxes += tax + taxes_to_flatten += tax.children_tax_ids + analytic_tag_ids = [(4, analytic_tag.id, None) for analytic_tag in line.analytic_tag_ids] + tax_repartition_field_name = 'invoice_repartition_line_ids' if line.invoice_type in ('in_invoice', 'out_invoice') else 'refund_repartition_line_ids' + tag_ids = flattened_taxes.mapped(tax_repartition_field_name).filtered(lambda x: x.repartition_type == 'base').mapped('tag_ids.id') move_line_dict = { 'invl_id': line.id, 'type': 'src', @@ -1218,25 +1258,20 @@ class AccountInvoice(models.Model): 'uom_id': line.uom_id.id, 'account_analytic_id': line.account_analytic_id.id, 'analytic_tag_ids': analytic_tag_ids, - 'tax_ids': tax_ids, + 'tax_ids': [(6, 0, flattened_taxes.ids)], 'invoice_id': self.id, + 'tag_ids': [(6, 0, tag_ids)], } res.append(move_line_dict) return res - @api.model def tax_line_move_line_get(self): + self.ensure_one() + res = [] - # keep track of taxes already processed - done_taxes = [] # loop the invoice.tax.line in reversal sequence for tax_line in sorted(self.tax_line_ids, key=lambda x: -x.sequence): if tax_line.amount_total: - tax = tax_line.tax_id - if tax.amount_type == "group": - for child_tax in tax.children_tax_ids: - done_taxes.append(child_tax.id) - analytic_tag_ids = [(4, analytic_tag.id, None) for analytic_tag in tax_line.analytic_tag_ids] res.append({ 'invoice_tax_line_id': tax_line.id, @@ -1250,9 +1285,10 @@ class AccountInvoice(models.Model): 'account_analytic_id': tax_line.account_analytic_id.id, 'analytic_tag_ids': analytic_tag_ids, 'invoice_id': self.id, - 'tax_ids': [(6, 0, list(done_taxes))] if tax_line.tax_id.include_base_amount else [] + 'tax_ids': tax_line.tax_ids and [(6, 0, tax_line.tax_ids.ids)] or False, # We don't pass an empty recordset here, as it would reset the tax_exibility of the line, due to the condition in account.move.line's create + 'tax_repartition_line_id': tax_line.tax_repartition_line_id.id, + 'tag_ids': [(6, 0, tax_line.tax_repartition_line_id.tag_ids.ids)], }) - done_taxes.append(tax.id) return res def inv_line_characteristic_hashcode(self, invoice_line): @@ -1514,21 +1550,6 @@ class AccountInvoice(models.Model): result.append((0, 0, values)) return result - @api.model - def _refund_tax_lines_account_change(self, lines, taxes_to_change): - # Let's change the account on tax lines when - # @param {list} lines: a list of orm commands - # @param {dict} taxes_to_change - # key: tax ID, value: refund account - - if not taxes_to_change: - return lines - - for line in lines: - if isinstance(line[2], dict) and line[2]['tax_id'] in taxes_to_change: - line[2]['account_id'] = taxes_to_change[line[2]['tax_id']] - return lines - def _get_refund_common_fields(self): return ['partner_id', 'payment_term_id', 'account_id', 'currency_id', 'journal_id'] @@ -1550,6 +1571,87 @@ class AccountInvoice(models.Model): def _get_currency_rate_date(self): return self.date or self.date_invoice + @api.model + def _create_refund_repartition_mapping(self, taxes): + """ Creates a mapping between tax and refund repartition lines of the + provided taxes, using the sequence of repartition lines to match them. + This function is used in order to fix account.invoice.tax objects generated + for refunds when tax amounts have been modified manually. + + For example, a tax with the following tax repartition... + - INVOICE: + 1) 30% + 2) 70% + - REFUND + 3) 40% + 4) 60% + + ... will group line 1) with line 3), and line 2) with lines 3) and 4). + + :return: A dictionnary, with invoice repartition line ids as keys, and lists + of corresponding refund repartition line ids as values. + """ + rslt = {} + for tax in taxes: + inv_index = 0 + ref_index = 0 + + tax_inv_lines = tax.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax') + tax_ref_lines = tax.refund_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax') + + amount_to_match = tax_inv_lines[inv_index].factor_percent + while inv_index < len(tax_inv_lines) or ref_index < len(tax_ref_lines): + inv_rep_line = inv_index < len(tax_inv_lines) and tax_inv_lines[inv_index] or tax_inv_lines[-1] + ref_rep_line = ref_index < len(tax_ref_lines) and tax_ref_lines[ref_index] or tax_ref_lines[-1] + + rslt_list = rslt.get(inv_rep_line.id, []) + if ref_rep_line.id not in rslt_list: + rslt_list.append(ref_rep_line.id) + rslt[inv_rep_line.id] = rslt_list + + if amount_to_match > 0: + amount_to_match -= ref_rep_line.factor_percent + ref_index += 1 + elif amount_to_match < 0: + amount_to_match += inv_rep_line.factor_percent + inv_index +=1 + else: + inv_index += 1 + if inv_index < len(tax_inv_lines): + amount_to_match = tax_inv_lines[inv_index].factor_percent + + return rslt + + def _group_tax_lines_by_repartition(self): + self.ensure_one() + rslt = {} + for tax_line in self.tax_line_ids: + rslt[tax_line.tax_repartition_line_id.id] = tax_line + return rslt + + @api.model + def _fix_refund_tax_lines(self, invoice, refund): + """ Modifies the tax_line_ids of a draft refund invoice in order to make it + match the manual tax modifications that were made on its original invoice. + """ + invoice_tax_lines_map = invoice._group_tax_lines_by_repartition() + refund_tax_lines_map = refund._group_tax_lines_by_repartition() + refund_repartition_map = self._create_refund_repartition_mapping(invoice.mapped('invoice_line_ids.invoice_line_tax_ids')) + computed_tax_values = invoice.get_taxes_values() + + for tax_data in computed_tax_values.values(): + rep_line_id = tax_data['tax_repartition_line_id'] + + matching_invoice_tax = invoice_tax_lines_map.get(rep_line_id) + + if matching_invoice_tax: + ref_rep_line_id = refund_repartition_map[rep_line_id][0] + refund_tax_line = refund_tax_lines_map.get(ref_rep_line_id) + manual_difference = matching_invoice_tax.amount - tax_data['amount'] + + if not invoice.currency_id.is_zero(manual_difference): + refund_tax_line.amount += manual_difference + @api.model def _prepare_refund(self, invoice, date_invoice=None, date=None, description=None, journal_id=None): """ Prepare the dict of values to create the new credit note from the invoice. @@ -1573,14 +1675,6 @@ class AccountInvoice(models.Model): values['invoice_line_ids'] = self._refund_cleanup_lines(invoice.invoice_line_ids) - tax_lines = invoice.tax_line_ids - taxes_to_change = { - line.tax_id.id: line.tax_id.refund_account_id.id - for line in tax_lines.filtered(lambda l: l.tax_id.refund_account_id != l.tax_id.account_id) - } - cleaned_tax_lines = self._refund_cleanup_lines(tax_lines) - values['tax_line_ids'] = self._refund_tax_lines_account_change(cleaned_tax_lines, taxes_to_change) - if journal_id: journal = self.env['account.journal'].browse(journal_id) elif invoice['type'] == 'in_invoice': @@ -1621,6 +1715,7 @@ class AccountInvoice(models.Model): message = _("This customer invoice credit note has been created from: %s
Reason: %s") % (invoice.id, invoice.number, description) else: message = _("This vendor bill credit note has been created from: %s
Reason: %s") % (invoice.id, invoice.number, description) + self._fix_refund_tax_lines(invoice, refund_invoice) refund_invoice.message_post(body=message) new_invoices += refund_invoice @@ -1740,7 +1835,7 @@ class AccountInvoiceLine(models.Model): price = self.price_unit * (1 - (self.discount or 0.0) / 100.0) taxes = False if self.invoice_line_tax_ids: - taxes = self.invoice_line_tax_ids.compute_all(price, currency, self.quantity, product=self.product_id, partner=self.invoice_id.partner_id) + taxes = self.invoice_line_tax_ids.compute_all(price, currency, self.quantity, product=self.product_id, partner=self.invoice_id.partner_id, is_refund=self.invoice_id.type in ('in_refund', 'out_refund')) self.price_subtotal = price_subtotal_signed = taxes['total_excluded'] if taxes else self.quantity * price self.price_total = taxes['total_included'] if taxes else self.price_subtotal if self.invoice_id.currency_id and self.invoice_id.currency_id != self.invoice_id.company_id.currency_id: @@ -1842,14 +1937,14 @@ class AccountInvoiceLine(models.Model): def _set_taxes(self): """ Used in on_change to set taxes and price""" self.ensure_one() - if self.invoice_id.type in ('out_invoice', 'out_refund'): - taxes = self.product_id.taxes_id or self.account_id.tax_ids or self.invoice_id.company_id.account_sale_tax_id - else: - taxes = self.product_id.supplier_taxes_id or self.account_id.tax_ids or self.invoice_id.company_id.account_purchase_tax_id # Keep only taxes of the company company_id = self.company_id or self.env.company_id - taxes = taxes.filtered(lambda r: r.company_id == company_id) + + if self.invoice_id.type in ('out_invoice', 'out_refund'): + taxes = self.product_id.taxes_id.filtered(lambda r: r.company_id == company_id) or self.account_id.tax_ids or self.invoice_id.company_id.account_sale_tax_id + else: + taxes = self.product_id.supplier_taxes_id.filtered(lambda r: r.company_id == company_id) or self.account_id.tax_ids or self.invoice_id.company_id.account_purchase_tax_id self.invoice_line_tax_ids = fp_taxes = self.invoice_id.fiscal_position_id.map_tax(taxes, self.product_id, self.invoice_id.partner_id) @@ -2026,7 +2121,7 @@ class AccountInvoiceLine(models.Model): class AccountInvoiceTax(models.Model): _name = "account.invoice.tax" _description = "Invoice Tax" - _order = 'sequence' + _order = 'sequence, id desc' def _prepare_invoice_tax_val(self): self.ensure_one() @@ -2034,25 +2129,13 @@ class AccountInvoiceTax(models.Model): 'tax_id': self.tax_id.id, 'account_id': self.account_id.id, 'account_analytic_id': self.account_analytic_id.id, - 'analytic_tag_ids': self.analytic_tag_ids.ids or False} - - @api.depends('invoice_id.invoice_line_ids') - def _compute_base_amount(self): - tax_grouped = {} - for invoice in self.mapped('invoice_id'): - tax_grouped[invoice.id] = invoice.get_taxes_values() - for tax in self: - tax.base = 0.0 - if tax.tax_id: - key = tax.tax_id.get_grouping_key(tax._prepare_invoice_tax_val()) - if tax.invoice_id and key in tax_grouped[tax.invoice_id.id]: - tax.base = tax_grouped[tax.invoice_id.id][key]['base'] - else: - _logger.warning('Tax Base Amount not computable probably due to a change in an underlying tax (%s).', tax.tax_id.name) + 'analytic_tag_ids': self.analytic_tag_ids.ids or False, + } invoice_id = fields.Many2one('account.invoice', string='Invoice', ondelete='cascade', index=True) name = fields.Char(string='Tax Description', required=True) tax_id = fields.Many2one('account.tax', string='Tax', ondelete='restrict') + tax_repartition_line_id = fields.Many2one(string="Originating Repartition Line", comodel_name='account.tax.repartition.line') account_id = fields.Many2one('account.account', string='Tax Account', required=True, domain=[('deprecated', '=', False)]) account_analytic_id = fields.Many2one('account.analytic.account', string='Analytic account') analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') @@ -2063,7 +2146,9 @@ class AccountInvoiceTax(models.Model): sequence = fields.Integer(help="Gives the sequence order when displaying a list of invoice tax.") company_id = fields.Many2one('res.company', string='Company', related='account_id.company_id', store=True, readonly=True) currency_id = fields.Many2one('res.currency', related='invoice_id.currency_id', store=True, readonly=True) - base = fields.Monetary(string='Base', compute='_compute_base_amount', store=True) + base = fields.Monetary(string='Base') + tax_ids = fields.Many2many('account.tax', string='Affecting Base Taxes', + help='Taxes affecting the tax base amount applied before this one.') @api.depends('amount', 'amount_rounding') def _compute_amount_total(self): diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index afb4e24c059..d6d56067152 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -2,11 +2,10 @@ import json from datetime import datetime, timedelta from babel.dates import format_datetime, format_date - from odoo import models, api, _, fields from odoo.release import version from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF, safe_eval -from odoo.tools.misc import formatLang +from odoo.tools.misc import formatLang, format_date as odoo_format_date import random class account_journal(models.Model): @@ -23,8 +22,41 @@ class account_journal(models.Model): elif (self.type in ['cash', 'bank']): self.kanban_dashboard_graph = json.dumps(self.get_line_graph_datas()) + def _get_json_activity_data(self): + for journal in self: + activities = [] + # search activity on move on the journal + sql_query = ''' + SELECT act.id, + act.res_id, + act.res_model, + act.summary, + act_type.name as act_type_name, + act_type.category as activity_category, + act.date_deadline, + CASE WHEN act.date_deadline < CURRENT_DATE THEN 'late' ELSE 'future' END as status + FROM account_move m + LEFT JOIN mail_activity act ON act.res_id = m.id + LEFT JOIN mail_activity_type act_type ON act.activity_type_id = act_type.id + WHERE act.res_model = 'account.move' + AND m.journal_id = %s + ''' + self.env.cr.execute(sql_query, (journal.id,)) + for activity in self.env.cr.dictfetchall(): + activities.append({ + 'id': activity.get('id'), + 'res_id': activity.get('res_id'), + 'res_model': activity.get('res_model'), + 'status': activity.get('status'), + 'name': activity.get('summary') or activity.get('act_type_name'), + 'activity_category': activity.get('activity_category'), + 'date': odoo_format_date(self.env, activity.get('date_deadline')) + }) + journal.json_activity_data = json.dumps({'activities': activities}) + kanban_dashboard = fields.Text(compute='_kanban_dashboard') kanban_dashboard_graph = fields.Text(compute='_kanban_dashboard_graph') + json_activity_data = fields.Text(compute='_get_json_activity_data') show_on_dashboard = fields.Boolean(string='Show journal on dashboard', help="Whether this journal should be displayed on the dashboard or not", default=True) color = fields.Integer("Color Index", default=0) @@ -215,6 +247,10 @@ class account_journal(models.Model): (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay, currency, curr_cache=curr_cache) (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts, currency, curr_cache=curr_cache) (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results, currency, curr_cache=curr_cache) + read = self.env['account.move'].read_group([('journal_id', '=', self.id), ('to_check', '=', True)], ['amount'], 'journal_id', lazy=False) + if read: + number_to_check = read[0]['__count'] + to_check_balance = read[0]['amount'] difference = currency.round(last_balance-account_sum) + 0.0 diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index a5d60435ff1..339191a3ee6 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -42,13 +42,38 @@ class AccountMove(models.Model): return result @api.multi - @api.depends('line_ids.debit', 'line_ids.credit') + @api.depends('line_ids.debit', 'line_ids.credit', 'line_ids.amount_currency', 'line_ids.currency_id') def _amount_compute(self): for move in self: total = 0.0 - for line in move.line_ids: + total_currency = 0.0 + currency_id = move.line_ids and move.line_ids[0].currency_id.id or False + for line in move.line_ids.filtered(lambda l: l.debit): total += line.debit - move.amount = total + if currency_id and line.currency_id.id == currency_id: + total_currency += line.amount_currency + elif currency_id: + currency_id = False + + if currency_id and total_currency: + move.amount = total_currency + move.currency_id = currency_id + else: + move.currency_id = move.company_id.currency_id or self.env.user.company_id.currency_id + move.amount = total + + @api.multi + def _set_amount(self): + for move in self: + if len(move.line_ids) == 2 and move.amount != 0: + amount_in_company_currency = move.amount + if move.currency_id and move.currency_id != move.company_id.currency_id: + amount_in_company_currency = move.currency_id._convert(move.amount, move.company_id.currency_id, move.company_id, move.date) + for line in move.line_ids: + line.amount_currency = line.debit and move.amount or -move.amount + for line in move.with_context(check_move_validity=False).line_ids: + line.debit = line.debit and amount_in_company_currency or 0.0 + line.credit = line.credit and amount_in_company_currency or 0.0 @api.depends('line_ids.debit', 'line_ids.credit', 'line_ids.matched_debit_ids.amount', 'line_ids.matched_credit_ids.amount', 'line_ids.account_id.user_type_id.type') def _compute_matched_percentage(self): @@ -62,12 +87,14 @@ class AccountMove(models.Model): if line.account_id.user_type_id.type in ('receivable', 'payable'): amount = abs(line.debit - line.credit) total_amount += amount - for partial_line in (line.matched_debit_ids + line.matched_credit_ids): - total_reconciled += partial_line.amount precision_currency = move.currency_id or move.company_id.currency_id if float_is_zero(total_amount, precision_rounding=precision_currency.rounding): move.matched_percentage = 1.0 else: + for line in move.line_ids: + if line.account_id.user_type_id.type in ('receivable', 'payable'): + for partial_line in (line.matched_debit_ids + line.matched_credit_ids): + total_reconciled += partial_line.amount move.matched_percentage = total_reconciled / total_amount @api.multi @@ -87,11 +114,6 @@ class AccountMove(models.Model): return [('id', 'in', rmi.ids)] return [('id', '=', False)] - @api.one - @api.depends('company_id') - def _compute_currency(self): - self.currency_id = self.company_id.currency_id or self.env.company_id.currency_id - @api.multi def _get_default_journal(self): if self.env.context.get('default_journal_type'): @@ -116,7 +138,6 @@ class AccountMove(models.Model): ref = fields.Char(string='Reference', copy=False) date = fields.Date(required=True, states={'posted': [('readonly', True)]}, index=True, default=fields.Date.context_today) journal_id = fields.Many2one('account.journal', string='Journal', required=True, states={'posted': [('readonly', True)]}, default=_get_default_journal) - currency_id = fields.Many2one('res.currency', compute='_compute_currency', store=True, string="Currency") state = fields.Selection([('draft', 'Unposted'), ('posted', 'Posted')], string='Status', required=True, readonly=True, copy=False, default='draft', help='All manually created new journal entries are usually in the status \'Unposted\', ' @@ -127,7 +148,8 @@ class AccountMove(models.Model): line_ids = fields.One2many('account.move.line', 'move_id', string='Journal Items', states={'posted': [('readonly', True)]}, copy=True) partner_id = fields.Many2one('res.partner', compute='_compute_partner_id', string="Partner", store=True, readonly=True) - amount = fields.Monetary(compute='_amount_compute', store=True) + amount = fields.Monetary(compute='_amount_compute', inverse="_set_amount", store=True) + currency_id = fields.Many2one('res.currency', compute='_amount_compute', store=True, string="Currency") narration = fields.Text(string='Internal Note') company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', store=True, readonly=True) matched_percentage = fields.Float('Percentage Matched', compute='_compute_matched_percentage', digits=0, store=True, readonly=True, help="Technical field used in cash basis method") @@ -139,13 +161,12 @@ class AccountMove(models.Model): string='Tax Cash Basis Entry of', help="Technical field used to keep track of the tax cash basis reconciliation. " "This is needed when cancelling the source: it will post the inverse journal entry to cancel that part too.") - 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.') + auto_post = fields.Boolean(string='Post Automatically', default=False, help='If this checkbox is ticked, this entry will be automatically posted at its date.') reverse_entry_id = fields.Many2one('account.move', String="Reverse entry", store=True, readonly=True) to_check = fields.Boolean(string='To Check', default=False, help='If this checkbox is ticked, it means that the user was not sure of all the related informations at the time of the creation of the move and that the move needs to be checked again.') tax_type_domain = fields.Char(store=False, help='Technical field used to have a dynamic taxes domain on the form view.') - @api.constrains('line_ids', 'journal_id', 'auto_reverse', 'reverse_date') + @api.constrains('line_ids', 'journal_id') def _validate_move_modification(self): if 'posted' in self.mapped('line_ids.payment_id.state'): raise ValidationError(_("You cannot modify a journal entry linked to a posted payment.")) @@ -183,23 +204,16 @@ class AccountMove(models.Model): '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): + def _find_existing_tax_line(line_ids, tax_repartition_line_id, analytic_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) + return line_ids.filtered(lambda x: x.tax_repartition_line_id.id == tax_repartition_line_id and x.analytic_tag_ids.ids == analytic_tag_ids and x.analytic_account_id.id == analytic_account_id) + return line_ids.filtered(lambda x: x.tax_repartition_line_id.id == tax_repartition_line_id) 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_id: - return tax.cash_basis_account_id - 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'] @@ -226,61 +240,59 @@ class AccountMove(models.Model): to_process_taxes = (taxes - processed_taxes).filtered(lambda t: t.amount_type != 'group') processed_taxes += to_process_taxes + # Apply tags on base line + line.tag_ids = taxes.mapped('invoice_repartition_line_ids').filtered(lambda x: x.repartition_type == 'base').mapped('tag_ids') + # 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) + 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. + if taxes_vals.get('taxes'): + for line_vals in taxes_vals['taxes']: + tax_line = _find_existing_tax_line(self.line_ids, line_vals['tax_repartition_line_id'], parsed_key['tag_ids'], parsed_key['analytic_account_id']) + if tax_line: + if not lines_to_sum: + # Drop tax line because the originator tax is no longer used. + self.line_ids -= tax_line + elif balance: + # Update the existing tax_line. + # Update the debit/credit amount according to the new balance. + amount = line_vals['amount'] + account = line_vals['account_id'] 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 + else: + # 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) + amount = line_vals['amount'] + to_create_vals = { + 'account_id': line_vals['account_id'] or line.account_id.id, + 'name': line_vals['name'], + '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, + 'tax_repartition_line_id': line_vals['tax_repartition_line_id'], + 'tag_ids': line_vals['tag_ids'], + } + # 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(to_create_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) @@ -314,6 +326,9 @@ class AccountMove(models.Model): # Create the analytic lines in batch is faster as it leads to less cache invalidation. self.mapped('line_ids').create_analytic_lines() for move in self: + if move.auto_post and move.date > fields.Date.today(): + raise UserError(_("This move is configured to be auto-posted on {}".format(move.date.strftime(self.env['res.lang']._lang_get(self.env.user.lang).date_format)))) + if move.name == '/': new_name = False journal = move.journal_id @@ -416,14 +431,14 @@ class AccountMove(models.Model): return True @api.multi - def _reverse_move(self, date=None, journal_id=None, auto=False): + def _reverse_move(self, date=None, journal_id=None): self.ensure_one() with self.env.norecompute(): reversed_move = self.copy(default={ 'date': date, 'journal_id': journal_id.id if journal_id else self.journal_id.id, - 'ref': (_('Automatic reversal of: %s') if auto else _('Reversal of: %s')) % (self.name), - 'auto_reverse': False}) + 'ref': _('Reversal of: %s') % (self.name), + }) for acm_line in reversed_move.line_ids.with_context(check_move_validity=False): acm_line.write({ 'debit': acm_line.credit, @@ -435,16 +450,14 @@ class AccountMove(models.Model): return reversed_move @api.multi - def reverse_moves(self, date=None, journal_id=None, auto=False): + def reverse_moves(self, date=None, journal_id=None): date = date or fields.Date.today() reversed_moves = self.env['account.move'] for ac_move in self: #unreconcile all lines reversed aml = ac_move.line_ids.filtered(lambda x: x.account_id.reconcile or x.account_id.internal_type == 'liquidity') aml.remove_move_reconcile() - reversed_move = ac_move._reverse_move(date=date, - journal_id=journal_id, - auto=auto) + reversed_move = ac_move._reverse_move(date=date, journal_id=journal_id) reversed_moves |= reversed_move #reconcile together the reconcilable (or the liquidity aml) and their newly created counterpart for account in set([x.account_id for x in aml]): @@ -475,18 +488,17 @@ class AccountMove(models.Model): return action @api.model - def _run_reverses_entries(self): - ''' This method is called from a cron job. ''' + def _run_post_draft_to_post(self): + ''' This method is called from a cron job. + It is used to post entries such as those created by the module + account_asset. + ''' records = self.search([ - ('state', '=', 'posted'), - ('auto_reverse', '=', True), - ('reverse_date', '<=', fields.Date.today()), - ('reverse_entry_id', '=', False)]) - for move in records: - date = None - if move.reverse_date and (not self.env.company_id.period_lock_date or move.reverse_date > self.env.company_id.period_lock_date): - date = move.reverse_date - move.reverse_moves(date=date, auto=True) + ('state', '=', 'draft'), + ('date', '<=', fields.Date.today()), + ('auto_post', '=', True), + ]) + records.post() @api.multi def action_view_reverse_entry(self): @@ -670,7 +682,8 @@ class AccountMoveLine(models.Model): date = fields.Date(related='move_id.date', string='Date', index=True, store=True, copy=False, readonly=False) # related is required analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines', oldname="analytic_lines") tax_ids = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) - tax_line_id = fields.Many2one('account.tax', string='Originator tax', ondelete='restrict') + tax_line_id = fields.Many2one('account.tax', string='Originator tax', ondelete='restrict', compute='_compute_tax_line_id', store=True) + tax_repartition_line_id = fields.Many2one(comodel_name='account.tax.repartition.line', string="Originator Tax Repartition Line", ondelete='restrict', help="Tax repartition line that caused the creation of this move line, if any") analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', index=True) analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') company_id = fields.Many2one('res.company', related='account_id.company_id', string='Company', store=True, readonly=True) @@ -684,6 +697,8 @@ class AccountMoveLine(models.Model): 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") + tag_ids = fields.Many2many(string="Tags", comodel_name='account.account.tag', ondelete='restrict', help="Tags assigned to this line by the tax creating it, if any. It determines its impact on financial reports.") + tax_audit = fields.Char(string="Tax Audit String", compute="_compute_tax_audit", store=True, help="Computed field, listing the tax grids impacted by this line, and the amount it applies to each of them.") _sql_constraints = [ ('credit_debit1', 'CHECK (credit*debit=0)', 'Wrong credit or debit value in accounting entry !'), @@ -753,12 +768,44 @@ class AccountMoveLine(models.Model): if (line.amount_currency > 0.0 and line.credit > 0.0) or (line.amount_currency < 0.0 and line.debit > 0.0): raise ValidationError(_('The amount expressed in the secondary currency must be positive when account is debited and negative when account is credited.')) + @api.depends('tax_repartition_line_id.invoice_tax_id', 'tax_repartition_line_id.refund_tax_id') + def _compute_tax_line_id(self): + """ tax_line_id is computed as the tax linked to the repartition line creating + the move. + """ + for record in self: + rep_line = record.tax_repartition_line_id + # A constraint on account.tax.repartition.line ensures both those fields are mutually exclusive + record.tax_line_id = rep_line.invoice_tax_id or rep_line.refund_tax_id + @api.depends('account_id.user_type_id') def _compute_is_unaffected_earnings_line(self): for record in self: unaffected_earnings_type = self.env.ref("account.data_unaffected_earnings") record.is_unaffected_earnings_line = unaffected_earnings_type == record.account_id.user_type_id + @api.depends('tag_ids', 'debit', 'credit', 'journal_id', 'invoice_id') + def _compute_tax_audit(self): + separator = ' ' + + for record in self: + currency = record.company_id.currency_id + audit_str = '' + for tag in record.tag_ids: + tag_amount = (tag.tax_negate and -1 or 1) * (record.journal_id.type == 'sale' and -1 or 1) * (record.invoice_id.type in ('in_refund', 'out_refund') and -1 or 1) * record.balance + + if tag.tax_report_line_ids: + #Then, the tag comes from a report line, and hence has a + or - sign (also in its name) + for report_line in tag.tax_report_line_ids: + audit_str += separator if audit_str else '' + audit_str += report_line.tag_name + ': ' + formatLang(self.env, tag_amount, currency_obj=currency) + else: + # Then, it's a financial tag (sign is always +, and never shown in tag name) + audit_str += separator if audit_str else '' + audit_str += tag.name + ': ' + formatLang(self.env, tag_amount, currency_obj=currency) + + record.tax_audit = audit_str + @api.onchange('amount_currency', 'currency_id', 'account_id') def _onchange_amount_currency(self): '''Recompute the debit/credit based on amount_currency/currency_id and date. @@ -786,22 +833,21 @@ class AccountMoveLine(models.Model): In case of full reconciliation, all moves belonging to the reconciliation will belong to the same account_full_reconcile object. """ # Get first all aml involved - part_recs = self.env['account.partial.reconcile'].search(['|', ('debit_move_id', 'in', self.ids), ('credit_move_id', 'in', self.ids)]) - amls = self - todo = set(part_recs) + todo = self.env['account.partial.reconcile'].search_read(['|', ('debit_move_id', 'in', self.ids), ('credit_move_id', 'in', self.ids)], ['debit_move_id', 'credit_move_id']) + amls = set(self.ids) seen = set() while todo: - partial_rec = todo.pop() - seen.add(partial_rec) - for aml in [partial_rec.debit_move_id, partial_rec.credit_move_id]: - if aml not in amls: - amls += aml - for x in aml.matched_debit_ids | aml.matched_credit_ids: - if x not in seen: - todo.add(x) - partial_rec_ids = [x.id for x in seen] + aml_ids = [rec['debit_move_id'][0] for rec in todo if rec['debit_move_id']] + [rec['credit_move_id'][0] for rec in todo if rec['credit_move_id']] + amls |= set(aml_ids) + seen |= set([rec['id'] for rec in todo]) + todo = self.env['account.partial.reconcile'].search_read(['&', '|', ('credit_move_id', 'in', aml_ids), ('debit_move_id', 'in', aml_ids), '!', ('id', 'in', list(seen))], ['debit_move_id', 'credit_move_id']) + + partial_rec_ids = list(seen) if not amls: return + else: + amls = self.browse(list(amls)) + # If we have multiple currency, we can only base ourselve on debit-credit to see if it is fully reconciled currency = set([a.currency_id for a in amls if a.currency_id.id != False]) multiple_currency = False @@ -1040,7 +1086,7 @@ class AccountMoveLine(models.Model): partner_id = self.env['res.partner']._find_accounting_partner(self[0].partner_id).id company_currency = self[0].account_id.company_id.currency_id - writeoff_currency = self[0].currency_id or company_currency + writeoff_currency = self[0].account_id.currency_id or company_currency line_to_reconcile = self.env['account.move.line'] # Iterate and create one writeoff by journal writeoff_moves = self.env['account.move'] @@ -1214,8 +1260,8 @@ class AccountMoveLine(models.Model): #Toggle the 'tax_exigible' field to False in case it is not yet given and the tax in 'tax_line_id' or one of #the 'tax_ids' is a cash based tax. taxes = False - if vals.get('tax_line_id'): - taxes = [{'tax_exigibility': self.env['account.tax'].browse(vals['tax_line_id']).tax_exigibility}] + if vals.get('tax_repartition_line_id'): + taxes = [{'tax_exigibility': self.env['account.tax.repartition.line'].browse(vals['tax_repartition_line_id']).tax_id.tax_exigibility}] if vals.get('tax_ids'): taxes = self.env['account.move.line'].resolve_2many_commands('tax_ids', vals['tax_ids']) if taxes and any([tax['tax_exigibility'] == 'on_payment' for tax in taxes]) and not vals.get('tax_exigible'): @@ -1668,7 +1714,7 @@ class AccountPartialReconcile(models.Model): 'name': line.name, 'debit': rounded_amt if rounded_amt > 0 else 0.0, 'credit': abs(rounded_amt) if rounded_amt < 0 else 0.0, - 'account_id': line.tax_line_id.cash_basis_account_id.id, + 'account_id': line.tax_repartition_line_id.account_id.id, 'analytic_account_id': line.analytic_account_id.id, 'analytic_tag_ids': line.analytic_tag_ids.ids, 'tax_line_id': line.tax_line_id.id, @@ -1677,6 +1723,8 @@ class AccountPartialReconcile(models.Model): 'currency_id': line.currency_id.id, 'move_id': newly_created_move.id, 'partner_id': line.partner_id.id, + 'tax_repartition_line_id': line.tax_repartition_line_id.id, + 'tag_ids': [(6, 0, line.tag_ids.ids)], }) if line.account_id.reconcile: #setting the account to allow reconciliation will help to fix rounding errors @@ -1700,6 +1748,8 @@ class AccountPartialReconcile(models.Model): 'currency_id': line.currency_id.id, 'amount_currency': self.amount_currency and line.currency_id.round(line.amount_currency * amount / line.balance) or 0.0, 'partner_id': line.partner_id.id, + 'tax_repartition_line_id': line.tax_repartition_line_id.id, + 'tag_ids': [(6, 0, line.tag_ids.ids)], }) self.env['account.move.line'].with_context(check_move_validity=False).create({ 'name': line.name, diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index bc20a820c9b..197bee837f7 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -108,6 +108,16 @@ class account_payment(models.Model): # Check all invoices are open if any(invoice.state != 'open' for invoice in invoices): raise UserError(_("You can only register payments for open invoices")) + # Check if, in batch payments, there are not negative invoices and positive invoices + dtype = invoices[0].type + for inv in invoices[1:]: + if inv.type != dtype: + if ((dtype == 'in_refund' and inv.type == 'in_invoice') or + (dtype == 'in_invoice' and inv.type == 'in_refund')): + raise UserError(_("You cannot register payments for vendor bills and supplier refunds at the same time.")) + if ((dtype == 'out_refund' and inv.type == 'out_invoice') or + (dtype == 'out_invoice' and inv.type == 'out_refund')): + raise UserError(_("You cannot register payments for customer invoices and credit notes at the same time.")) amount = self._compute_payment_amount(invoices, invoices[0].currency_id) rec.update({ @@ -660,6 +670,20 @@ class account_payment(models.Model): return vals + def _get_invoice_payment_amount(self, inv): + """ + Computes the amount covered by the current payment in the given invoice. + + :param inv: an invoice object + :returns: the amount covered by the payment in the invoice + """ + self.ensure_one() + return sum([ + data['amount'] + for data in inv._get_payments_vals() + if data['account_payment_id'] == self.id + ]) + class payment_register(models.TransientModel): _name = 'account.payment.register' diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 55dec0dfae2..ebfdde3757f 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -3,7 +3,7 @@ from odoo.exceptions import AccessError from odoo import api, fields, models, _ from odoo import SUPERUSER_ID -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.http import request import logging @@ -124,6 +124,9 @@ class AccountChartTemplate(models.Model): property_stock_account_input_categ_id = fields.Many2one('account.account.template', string="Input Account for Stock Valuation", oldname="property_stock_account_input_categ") property_stock_account_output_categ_id = fields.Many2one('account.account.template', string="Output Account for Stock Valuation", oldname="property_stock_account_output_categ") property_stock_valuation_account_id = fields.Many2one('account.account.template', string="Account Template for Stock Valuation") + property_tax_payable_account_id = fields.Many2one('account.account.template', string="Tax current account (payable)") + property_tax_receivable_account_id = fields.Many2one('account.account.template', string="Tax current account (receivable)") + property_advance_tax_payment_account_id = fields.Many2one('account.account.template', string="Advance tax payment account") @api.model def _prepare_transfer_account_template(self): @@ -413,7 +416,7 @@ class AccountChartTemplate(models.Model): journals = [{'name': _('Customer Invoices'), 'type': 'sale', 'code': _('INV'), 'favorite': True, 'color': 11, 'sequence': 5}, {'name': _('Vendor Bills'), 'type': 'purchase', 'code': _('BILL'), 'favorite': True, 'color': 11, 'sequence': 6}, - {'name': _('Miscellaneous Operations'), 'type': 'general', 'code': _('MISC'), 'favorite': False, 'sequence': 7}, + {'name': _('Miscellaneous Operations'), 'type': 'general', 'code': _('MISC'), 'favorite': True, 'sequence': 7}, {'name': _('Exchange Difference'), 'type': 'general', 'code': _('EXCH'), 'favorite': False, 'sequence': 9}, {'name': _('Cash Basis Taxes'), 'type': 'general', 'code': _('CABA'), 'favorite': False, 'sequence': 10}] if journals_dict != None: @@ -454,6 +457,9 @@ class AccountChartTemplate(models.Model): ('property_account_income_categ_id', 'product.category', 'account.account'), ('property_account_expense_id', 'product.template', 'account.account'), ('property_account_income_id', 'product.template', 'account.account'), + ('property_tax_payable_account_id', 'account.tax.group', 'account.account'), + ('property_tax_receivable_account_id', 'account.tax.group', 'account.account'), + ('property_advance_tax_payment_account_id', 'account.tax.group', 'account.account'), ] for record in todo_list: account = getattr(self, record[0]) @@ -547,15 +553,20 @@ class AccountChartTemplate(models.Model): account_ref.update(account_template_ref) # writing account values after creation of accounts - for key, value in generated_tax_res['account_dict'].items(): - if value['refund_account_id'] or value['account_id'] or value['cash_basis_account_id'] or value['cash_basis_base_account_id']: + for key, value in generated_tax_res['account_dict']['account.tax'].items(): + if value['cash_basis_transition_account_id'] or value['cash_basis_base_account_id']: AccountTaxObj.browse(key).write({ - 'refund_account_id': account_ref.get(value['refund_account_id'], False), - 'account_id': account_ref.get(value['account_id'], False), - 'cash_basis_account_id': account_ref.get(value['cash_basis_account_id'], False), + 'cash_basis_transition_account_id': account_ref.get(value['cash_basis_transition_account_id'], False), 'cash_basis_base_account_id': account_ref.get(value['cash_basis_base_account_id'], False), }) + AccountTaxRepartitionLineObj = self.env['account.tax.repartition.line'] + for key, value in generated_tax_res['account_dict']['account.tax.repartition.line'].items(): + if value['account_id']: + AccountTaxRepartitionLineObj.browse(key).write({ + 'account_id': account_ref.get(value['account_id']), + }) + # Create Journals - Only done for root chart template if not self.parent_id: self.generate_journals(account_ref, company) @@ -675,6 +686,7 @@ class AccountChartTemplate(models.Model): 'company_id': company.id, 'account_id': acc_template_ref[account_reconcile_model.account_id.id], 'label': account_reconcile_model.label, + 'to_check': account_reconcile_model.to_check, 'amount_type': account_reconcile_model.amount_type, 'force_tax_included': account_reconcile_model.force_tax_included, 'amount': account_reconcile_model.amount, @@ -795,18 +807,15 @@ class AccountTaxTemplate(models.Model): children_tax_ids = fields.Many2many('account.tax.template', 'account_tax_template_filiation_rel', 'parent_tax', 'child_tax', string='Children Taxes') sequence = fields.Integer(required=True, default=1, help="The sequence field is used to define order in which the tax lines are applied.") - amount = fields.Float(required=True, digits=(16, 4)) - account_id = fields.Many2one('account.account.template', string='Tax Account', ondelete='restrict', - help="Account that will be set on invoice tax lines for invoices. Leave empty to use the expense account.", oldname='account_collected_id') - refund_account_id = fields.Many2one('account.account.template', string='Tax Account on Refunds', ondelete='restrict', - help="Account that will be set on invoice tax lines for refunds. Leave empty to use the expense account.", oldname='account_paid_id') + amount = fields.Float(required=True, digits=(16, 4), default=0) description = fields.Char(string='Display on Invoices') price_include = fields.Boolean(string='Included in Price', default=False, help="Check this if the price you use on the product and invoices includes this tax.") include_base_amount = fields.Boolean(string='Affect Subsequent Taxes', default=False, help="If set, taxes which are computed after this one will be computed based on the price tax included.") analytic = fields.Boolean(string="Analytic Cost", help="If set, the amount computed by this tax will be assigned to the same analytic account as the invoice line (if any)") - tag_ids = fields.Many2many('account.account.tag', string='Account tag', help="Optional tags you may want to assign for custom reporting") + invoice_repartition_line_ids = fields.One2many(string="Repartition for Invoices", comodel_name="account.tax.repartition.line.template", inverse_name="invoice_tax_id", copy=True, help="Repartition when the tax is used on an invoice") + refund_repartition_line_ids = fields.One2many(string="Repartition for Refund Invoices", comodel_name="account.tax.repartition.line.template", inverse_name="refund_tax_id", copy=True, help="Repartition when the tax is used on a refund") tax_group_id = fields.Many2one('account.tax.group', string="Tax Group") tax_exigibility = fields.Selection( [('on_invoice', 'Based on Invoice'), @@ -815,12 +824,11 @@ class AccountTaxTemplate(models.Model): oldname='use_cash_basis', help="Based on Invoice: the tax is due as soon as the invoice is validated.\n" "Based on Payment: the tax is due as soon as the payment of the invoice is received.") - cash_basis_account_id = fields.Many2one( - 'account.account.template', - string='Tax Received Account', + cash_basis_transition_account_id = fields.Many2one( + comodel_name='account.account.template', + string="Cash Basis Transition Account", domain=[('deprecated', '=', False)], - oldname='cash_basis_account', - help='Account used as counterpart for the journal entry, for taxes eligible based on payments.') + help="Account used to transition the tax amount for cash basis taxes. It will contain the tax amount as long as the original invoice has not been reconciled ; at reconciliation, this amount cancelled on this account and put on the regular tax account.") cash_basis_base_account_id = fields.Many2one( 'account.account.template', domain=[('deprecated', '=', False)], @@ -861,11 +869,17 @@ class AccountTaxTemplate(models.Model): 'price_include': self.price_include, 'include_base_amount': self.include_base_amount, 'analytic': self.analytic, - 'tag_ids': [(6, 0, [t.id for t in self.tag_ids])], 'children_tax_ids': [(6, 0, children_ids)], 'tax_exigibility': self.tax_exigibility, } + # We add repartition lines if there are some, so that if there are none, + # default_get is called and creates the default ones properly. + if self.invoice_repartition_line_ids: + val['invoice_repartition_line_ids'] = self.invoice_repartition_line_ids.get_repartition_line_create_vals(company) + if self.refund_repartition_line_ids: + val['refund_repartition_line_ids'] = self.refund_repartition_line_ids.get_repartition_line_create_vals(company) + if self.tax_group_id: val['tax_group_id'] = self.tax_group_id.id return val @@ -881,7 +895,7 @@ class AccountTaxTemplate(models.Model): } """ ChartTemplate = self.env['account.chart.template'] - todo_dict = {} + todo_dict = {'account.tax': {}, 'account.tax.repartition.line': {}} tax_template_to_tax = {} templates_todo = list(self) @@ -890,27 +904,36 @@ class AccountTaxTemplate(models.Model): templates_todo = [] # create taxes in batch - template_vals = [] + tax_template_vals = [] for template in templates: if all(child.id in tax_template_to_tax for child in template.children_tax_ids): vals = template._get_tax_vals(company, tax_template_to_tax) - template_vals.append((template, vals)) + tax_template_vals.append((template, vals)) else: # defer the creation of this tax to the next batch templates_todo.append(template) - taxes = ChartTemplate._create_records_with_xmlid('account.tax', template_vals, company) + taxes = ChartTemplate._create_records_with_xmlid('account.tax', tax_template_vals, company) # fill in tax_template_to_tax and todo_dict - for tax, (template, vals) in zip(taxes, template_vals): + for tax, (template, vals) in zip(taxes, tax_template_vals): tax_template_to_tax[template.id] = tax.id # Since the accounts have not been created yet, we have to wait before filling these fields - todo_dict[tax.id] = { - 'account_id': template.account_id.id, - 'refund_account_id': template.refund_account_id.id, - 'cash_basis_account_id': template.cash_basis_account_id.id, + todo_dict['account.tax'][tax.id] = { + 'cash_basis_transition_account_id': template.cash_basis_transition_account_id.id, 'cash_basis_base_account_id': template.cash_basis_base_account_id.id, } + # We also have to delay the assignation of accounts to repartition lines + all_tax_rep_lines = tax.invoice_repartition_line_ids + tax.refund_repartition_line_ids + all_template_rep_lines = template.invoice_repartition_line_ids + template.refund_repartition_line_ids + for i in range(0, len(all_tax_rep_lines)): + # We assume template and tax repartition lines are in the same order + template_account = all_template_rep_lines[i].account_id + if template_account: + todo_dict['account.tax.repartition.line'][all_tax_rep_lines[i].id] = { + 'account_id': template_account.id, + } + if any(template.tax_exigibility == 'on_payment' for template in self): # When a CoA is being installed automatically and if it is creating account tax(es) whose field `Use Cash Basis`(tax_exigibility) is set to True by default # (example of such CoA's are l10n_fr and l10n_mx) then in the `Accounting Settings` the option `Cash Basis` should be checked by default. @@ -921,6 +944,79 @@ class AccountTaxTemplate(models.Model): 'account_dict': todo_dict } +# Tax Repartition Line Template + +class AccountTaxRepartitionLineTemplate(models.Model): + _name = "account.tax.repartition.line.template" + _description = "Tax Repartition Line Template" + + factor_percent = fields.Float(string="%", required=True, help="Factor to apply on the account move lines generated from this repartition line, in percents") + repartition_type = fields.Selection(string="Based On", selection=[('base', 'Base'), ('tax', 'of tax')], required=True, default='tax', help="Base on which the factor will be applied.") + account_id = fields.Many2one(string="Account", comodel_name='account.account.template', help="Account on which to post the tax amount") + invoice_tax_id = fields.Many2one(comodel_name='account.tax.template', help="The tax set to apply this repartition on invoices. Mutually exclusive with refund_tax_id") + refund_tax_id = fields.Many2one(comodel_name='account.tax.template', help="The tax set to apply this repartition on refund invoices. Mutually exclusive with invoice_tax_id") + tag_ids = fields.Many2many(string="Financial Tags", relation='account_tax_repartition_financial_tags', comodel_name='account.account.tag', copy=True, help="Additional tags that will be assigned by this repartition line for use in financial reports") + + # These last two fields are helpers used to ease the declaration of account.account.tag objects in XML. + # They are directly linked to account.tax.report.line objects, which create corresponding + and - tags + # at creation. This way, we avoid declaring + and - separately every time. + plus_report_line_ids = fields.Many2many(string="Plus Tax Report Lines", relation='account_tax_repartition_plus_report_line', comodel_name='account.tax.report.line', copy=True, help="Tax report lines whose '+' tag will be assigned to move lines by this repartition line") + minus_report_line_ids = fields.Many2many(string="Minus Report Lines", relation='account_tax_repartition_minus_report_line', comodel_name='account.tax.report.line', copy=True, help="Tax report lines whose '-' tag will be assigned to move lines by this repartition line") + + @api.model + def create(self, vals): + if vals.get('plus_report_line_ids'): + vals['plus_report_line_ids'] = self._convert_tag_syntax_to_orm(vals['plus_report_line_ids']) + + if vals.get('minus_report_line_ids'): + vals['minus_report_line_ids'] = self._convert_tag_syntax_to_orm(vals['minus_report_line_ids']) + + if vals.get('tag_ids'): + vals['tag_ids'] = self._convert_tag_syntax_to_orm(vals['tag_ids']) + + return super(AccountTaxRepartitionLineTemplate, self).create(vals) + + @api.model + def _convert_tag_syntax_to_orm(self, tags_list): + """ Repartition lines give the possibility to directly give + a list of ids to create for tags instead of a list of ORM commands. + + This function checks that tags_list uses this syntactic sugar and returns + an ORM-compliant version of it if it does. + """ + if tags_list and all(isinstance(elem, int) for elem in tags_list): + return [(6, False, tags_list)] + return tags_list + + @api.constrains('invoice_tax_id', 'refund_tax_id') + def validate_tax_template_link(self): + for record in self: + if record.invoice_tax_id and record.refund_tax_id: + raise ValidationError(_("Tax repartition line templates should apply to either invoices or refunds, not both at the same time. invoice_tax_id and refund_tax_id should not be set together.")) + + @api.constrains('plus_report_line_ids', 'minus_report_line_ids') + def validate_tags(self): + all_tax_rep_lines = self.mapped('plus_report_line_ids') + self.mapped('minus_report_line_ids') + lines_without_tag = all_tax_rep_lines.filtered(lambda x: not x.tag_name) + if lines_without_tag: + raise ValidationError(_("The following tax report lines are used in some tax repartition template though they don't generate any tag: %s . This probably means you forgot to set a tag_name on these lines.") % str(lines_without_tag.mapped('name'))) + + def get_repartition_line_create_vals(self, company): + rslt = [(5, 0, 0)] + for record in self: + tags_to_add = self.env['account.account.tag'] + tags_to_add += record.plus_report_line_ids.mapped('tag_ids').filtered(lambda x: not x.tax_negate) + tags_to_add += record.minus_report_line_ids.mapped('tag_ids').filtered(lambda x: x.tax_negate) + tags_to_add += record.tag_ids + + rslt.append((0, 0, { + 'factor_percent': record.factor_percent, + 'repartition_type': record.repartition_type, + 'tag_ids': [(6, 0, tags_to_add.ids)], + 'company_id': company.id, + })) + return rslt + # Fiscal Position Templates class AccountFiscalPositionTemplate(models.Model): diff --git a/addons/account/models/product.py b/addons/account/models/product.py index ec818721697..0bf02614962 100644 --- a/addons/account/models/product.py +++ b/addons/account/models/product.py @@ -79,4 +79,6 @@ class ProductProduct(models.Model): 'tax_ids': line.get('tax_ids', False), 'tax_line_id': line.get('tax_line_id', False), 'analytic_tag_ids': line.get('analytic_tag_ids', False), + 'tax_repartition_line_id': line.get('tax_repartition_line_id'), + 'tag_ids': line.get('tag_ids'), } diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index 0e267d706ed..9e7fd8a0c1d 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -689,10 +689,12 @@ class AccountReconciliation(models.AbstractModel): 'company_id': st_line.company_id.id, } if st_line.partner_id: - if amount > 0: + if st_line.partner_id.supplier and not st_line.partner_id.customer: + data['open_balance_account_id'] = st_line.partner_id.property_account_payable_id.id + elif not st_line.partner_id.supplier and st_line.partner_id.customer: data['open_balance_account_id'] = st_line.partner_id.property_account_receivable_id.id else: - data['open_balance_account_id'] = st_line.partner_id.property_account_payable_id.id + data['open_balance_account_id'] = amount > 0 and st_line.partner_id.property_account_receivable_id.id or st_line.partner_id.property_account_payable_id.id return data diff --git a/addons/account/models/res_users.py b/addons/account/models/res_users.py index ab85e359d22..8030423e74d 100644 --- a/addons/account/models/res_users.py +++ b/addons/account/models/res_users.py @@ -14,8 +14,8 @@ class Users(models.Model): super(Users, self)._check_one_user_type() users_with_both_groups = self.filtered(lambda user: - user.user_has_groups('account.group_show_line_subtotals_tax_included') and - user.user_has_groups('account.group_show_line_subtotals_tax_excluded') + user.has_group('account.group_show_line_subtotals_tax_included') and + user.has_group('account.group_show_line_subtotals_tax_excluded') ) if users_with_both_groups: names = ", ".join(users_with_both_groups.mapped('name')) diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index 16be4f2d247..029b06d5334 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -64,6 +64,17 @@ + + account.analytic.line.billing.user + + [(1, '=', 1)] + + + + + + + @@ -124,6 +135,13 @@ ['|',('company_id','=',False),('company_id', 'in', company_ids)] + + Tax report multi-company + + + [('country_id','=',user.company_id.country_id.id)] + + Invoice multi-company diff --git a/addons/account/security/ir.model.access.csv b/addons/account/security/ir.model.access.csv index 2f74ad224ae..27828b3824c 100644 --- a/addons/account/security/ir.model.access.csv +++ b/addons/account/security/ir.model.access.csv @@ -86,3 +86,9 @@ access_account_incoterms_all,account.incoterms all,model_account_incoterms,,1,0, access_account_incoterms_manager,account.incoterms manager,model_account_incoterms,account.group_account_manager,1,1,1,1 access_account_journal_group_all,account.journal.group all,model_account_journal_group,,1,0,0,0 access_account_journal_group_manager,account.journal.group manager,model_account_journal_group,account.group_account_manager,1,1,1,1 +access_account_tax_repartition_line_user,account.tax repartition.line.user,model_account_tax_repartition_line,base.group_user,1,0,0,0 +access_account_tax_repartition_line_invoice,account.tax repartition.line.invoice,model_account_tax_repartition_line,account.group_account_invoice,1,0,0,0 +access_account_tax_repartition_line_manager,account.tax repartition.line.manager,model_account_tax_repartition_line,account.group_account_manager,1,1,1,1 +access_account_tax_repartition_line_template_manager,account.tax repartition.line.template.manager,model_account_tax_repartition_line_template,account.group_account_manager,1,1,1,1 +access_account_tax_report_line_invoice,account.tax.report.line.invoice,model_account_tax_report_line,account.group_account_invoice,1,0,0,0 +access_account_tax_report_line_ac_user,account.tax.report.line.ac.user,model_account_tax_report_line,account.group_account_manager,1,1,1,1 diff --git a/addons/account/static/src/js/mail_activity.js b/addons/account/static/src/js/mail_activity.js new file mode 100644 index 00000000000..8b84afda726 --- /dev/null +++ b/addons/account/static/src/js/mail_activity.js @@ -0,0 +1,69 @@ +odoo.define('account.activity', function (require) { +"use strict"; + +var AbstractField = require('web.AbstractField'); +var core = require('web.core'); +var field_registry = require('web.field_registry'); + +var QWeb = core.qweb; +var _t = core._t; + +var VatActivity = AbstractField.extend({ + className: 'o_journal_activity_kanban', + events: { + 'click .see_all_activities': '_onOpenAll', + 'click .see_activity': '_onOpenActivity', + }, + init: function () { + this.MAX_ACTIVITY_DISPLAY = 5; + this._super.apply(this, arguments); + }, + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + _render: function () { + var self = this; + var info = JSON.parse(this.value); + if (!info) { + this.$el.html(''); + return; + } + info.more_activities = false; + if (info.activities.length > this.MAX_ACTIVITY_DISPLAY) { + info.more_activities = true; + info.activities = info.activities.slice(0, this.MAX_ACTIVITY_DISPLAY); + } + this.$el.html(QWeb.render('accountJournalDashboardActivity', info)); + }, + + _onOpenActivity: function(e) { + e.preventDefault(); + var self = this; + self.do_action({ + type: 'ir.actions.act_window', + name: _t('Journal Entry'), + target: 'current', + res_id: $(e.target).data('resId'), + res_model: 'account.move', + views: [[false, 'form']], + }); + }, + + _onOpenAll: function(e) { + e.preventDefault(); + var self = this; + self.do_action({ + type: 'ir.actions.act_window', + name: _t('Journal Entries'), + res_model: 'account.move', + views: [[false, 'kanban'], [false, 'form']], + search_view_id: [false], + domain: [['journal_id', '=', self.res_id], ['activity_ids', '!=', false]], + }); + } +}) + +field_registry.add('kanban_vat_activity', VatActivity); + +return VatActivity; +}); diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index 601f4cec773..c28790796d2 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -837,11 +837,17 @@ var StatementModel = BasicModel.extend({ return this._rpc({ model: 'res.partner', method: 'read', - args: [partner_id, ["property_account_receivable_id", "property_account_payable_id"]], + args: [partner_id, ["property_account_receivable_id", "property_account_payable_id", "customer", "supplier"]], }).then(function (result) { if (result.length > 0) { var line = self.getLine(handle); - self.lines[handle].st_line.open_balance_account_id = line.balance.amount < 0 ? result[0]['property_account_payable_id'][0] : result[0]['property_account_receivable_id'][0]; + if (result[0]['supplier'] && !result[0]['customer']) { + self.lines[handle].st_line.open_balance_account_id = result[0]['property_account_payable_id'][0]; + } else if (!result[0]['supplier'] && result[0]['customer']) { + self.lines[handle].st_line.open_balance_account_id = result[0]['property_account_receivable_id'][0]; + } else { + self.lines[handle].st_line.open_balance_account_id = line.balance.amount < 0 ? result[0]['property_account_payable_id'][0] : result[0]['property_account_receivable_id'][0]; + } } }); }, diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index 33b4430ab2f..b59b99bc881 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -277,6 +277,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { 'field_changed': '_onFieldChanged', }), _avoidFieldUpdate: {}, + MV_LINE_DEBOUNCE: 200, /** * create partner_id field in editable mode @@ -289,6 +290,11 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { this.model = model; this._initialState = state; + if (this.MV_LINE_DEBOUNCE) { + this._onSelectMoveLine = _.debounce(this._onSelectMoveLine, this.MV_LINE_DEBOUNCE, true); + } else { + this._onSelectMoveLine = this._onSelectMoveLine; + } }, /** * @override diff --git a/addons/account/static/src/scss/account_activity.scss b/addons/account/static/src/scss/account_activity.scss new file mode 100644 index 00000000000..b4514086cd0 --- /dev/null +++ b/addons/account/static/src/scss/account_activity.scss @@ -0,0 +1,8 @@ +.o_journal_activity_kanban { + display: block; + .align_activity_center { + width: 100%; + align-items: center; + margin-bottom: 5px; + } +} \ No newline at end of file diff --git a/addons/account/static/src/scss/account_journal_dashboard.scss b/addons/account/static/src/scss/account_journal_dashboard.scss index 711021fbbad..b842a94f058 100644 --- a/addons/account/static/src/scss/account_journal_dashboard.scss +++ b/addons/account/static/src/scss/account_journal_dashboard.scss @@ -41,6 +41,15 @@ @include o-position-absolute($bottom: 25%, $right: 0); z-index: 1; } + .o_kanban_group { + &:not(.o_column_folded) { + width: 450px + 2*$o-kanban-group-padding; + + @include media-breakpoint-down(sm) { + width: 100%; + } + } + } } // Style for the widget "dashboard_graph" diff --git a/addons/account/static/src/xml/account_journal_activity.xml b/addons/account/static/src/xml/account_journal_activity.xml new file mode 100644 index 00000000000..a6e73e5659d --- /dev/null +++ b/addons/account/static/src/xml/account_journal_activity.xml @@ -0,0 +1,21 @@ + + + + + + +
+
+ + + +
+
+ +
+
+
+ See all activities +
+ +
\ No newline at end of file diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 5e8d71b466d..0248d0cec7e 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -585,6 +585,7 @@ odoo.define('account.reconciliation_tests', function (require) { "use strict"; var ReconciliationClientAction = require('account.ReconciliationClientAction'); +var ReconciliationRenderer = require('account.ReconciliationRenderer'); var demoData = require('account.reconciliation_tests.data'); var testUtils = require('web.test_utils'); @@ -592,7 +593,13 @@ var testUtils = require('web.test_utils'); QUnit.module('account', { beforeEach: function () { this.params = demoData.getParams(); - } + testUtils.patch(ReconciliationRenderer.LineRenderer, { + MV_LINE_DEBOUNCE: 0, + }); + }, + afterEach: function () { + testUtils.unpatch(ReconciliationRenderer.LineRenderer); + }, }, function () { QUnit.module('Reconciliation'); diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index e87ae79c27a..0eb1d84e613 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -16,6 +16,7 @@ from . import test_product_id_change from . import test_reconciliation from . import test_search from . import test_tax +from . import test_invoice_taxes from . import test_account_move_taxes_edition from . import test_templates_consistency from . import test_account_fiscal_year diff --git a/addons/account/tests/test_account_customer_invoice.py b/addons/account/tests/test_account_customer_invoice.py index b3fe591092b..4ee92c99762 100644 --- a/addons/account/tests/test_account_customer_invoice.py +++ b/addons/account/tests/test_account_customer_invoice.py @@ -166,7 +166,7 @@ class TestAccountCustomerInvoice(AccountTestUsers): invoice_line_ids=invoice_line_data )) - self.assertEquals(invoice.amount_untaxed, sum([x.base for x in invoice.tax_line_ids])) + self.assertAlmostEquals(invoice.amount_untaxed, sum([x.base for x in invoice.tax_line_ids])) def test_customer_invoice_tax_refund(self): company = self.env.company_id @@ -193,8 +193,32 @@ class TestAccountCustomerInvoice(AccountTestUsers): 'amount': 15.0, 'amount_type': 'percent', 'type_tax_use': 'sale', - 'account_id': tax_account.id, - 'refund_account_id': tax_refund_account.id + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': tax_account.id, + }), + + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': tax_refund_account.id, + }), + + ], }) invoice_line_data = [ diff --git a/addons/account/tests/test_account_move_tax_lock_date.py b/addons/account/tests/test_account_move_tax_lock_date.py index 0f119741088..7afd4b7d0db 100644 --- a/addons/account/tests/test_account_move_tax_lock_date.py +++ b/addons/account/tests/test_account_move_tax_lock_date.py @@ -17,6 +17,7 @@ class TestTaxBlockDate(AccountingTestCase): def setUp(self): super(TestTaxBlockDate, self).setUp() self.user_id = self.env.user + company_id = self.user_id.company_id.id last_day_month = datetime.now() last_day_month = last_day_month.replace(day=monthrange(last_day_month.year, last_day_month.month)[1]) @@ -28,10 +29,10 @@ class TestTaxBlockDate(AccountingTestCase): middle_day_month = middle_day_month.replace(day=15) self.middle_day_month_str = middle_day_month.strftime(DEFAULT_SERVER_DATE_FORMAT) - self.sale_journal_id = self.env['account.journal'].search([('type', '=', 'sale')], limit=1)[0] - self.account_id = self.env['account.account'].search([('internal_type', '=', 'receivable')], limit=1)[0] - self.other_account_id = self.env['account.account'].search([('internal_type', '!=', 'receivable')], limit=1)[0] - self.tax_id = self.env['account.tax'].search([], limit=1)[0] + self.sale_journal_id = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company_id)], limit=1)[0] + self.account_id = self.env['account.account'].search([('internal_type', '=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0] + self.other_account_id = self.env['account.account'].search([('internal_type', '!=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0] + self.tax_id = self.env['account.tax'].search([('company_id', '=', company_id)], limit=1)[0] self.move = { 'name': '/', diff --git a/addons/account/tests/test_invoice_taxes.py b/addons/account/tests/test_invoice_taxes.py new file mode 100644 index 00000000000..4b0d8380f72 --- /dev/null +++ b/addons/account/tests/test_invoice_taxes.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.tests.common import Form +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestInvoiceTaxes(AccountingTestCase): + + def setUp(self): + super(TestInvoiceTaxes, self).setUp() + + self.percent_tax_1 = self.env['account.tax'].create({ + 'name': '21%', + 'amount_type': 'percent', + 'amount': 21, + 'sequence': 10, + }) + self.percent_tax_1_incl = self.env['account.tax'].create({ + 'name': '21% incl', + 'amount_type': 'percent', + 'amount': 21, + 'price_include': True, + 'include_base_amount': True, + 'sequence': 20, + }) + self.percent_tax_2 = self.env['account.tax'].create({ + 'name': '12%', + 'amount_type': 'percent', + 'amount': 12, + 'sequence': 30, + }) + self.group_tax = self.env['account.tax'].create({ + 'name': 'group 12% + 21%', + 'amount_type': 'group', + 'amount': 21, + 'children_tax_ids': [ + (4, self.percent_tax_1_incl.id), + (4, self.percent_tax_2.id) + ], + 'sequence': 40, + }) + + def _create_invoice(self, taxes_per_line, inv_type='out_invoice'): + ''' Create an invoice on the fly. + + :param taxes_per_line: A list of tuple (price_unit, account.tax recordset) + ''' + self_ctx = self.env['account.invoice'].with_context(type=inv_type) + journal_id = self_ctx._default_journal().id + self_ctx = self_ctx.with_context(journal_id=journal_id) + + with Form(self_ctx, view=(inv_type in ('out_invoice', 'out_refund') and 'account.invoice_form' or 'invoice_supplier_form')) as invoice_form: + invoice_form.partner_id = self.env.ref('base.partner_demo') + + for amount, taxes in taxes_per_line: + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.name = 'xxxx' + invoice_line_form.quantity = 1 + invoice_line_form.price_unit = amount + invoice_line_form.invoice_line_tax_ids.clear() + for tax in taxes: + invoice_line_form.invoice_line_tax_ids.add(tax) + return invoice_form.save() + + def test_one_tax_per_line(self): + ''' Test: + price_unit | Taxes + ------------------ + 100 | 21% + 121 | 21% incl + 100 | 12% + + Expected: + Tax | Taxes | Base | Amount + -------------------------------------------- + 21% | / | 100 | 21 + 21% incl | / | 100 | 21 + 12% | / | 100 | 12 + ''' + invoice = self._create_invoice([ + (100, self.percent_tax_1), + (121, self.percent_tax_1_incl), + (100, self.percent_tax_2), + ]) + invoice.action_invoice_open() + self.assertRecordValues(invoice.tax_line_ids, [ + {'name': self.percent_tax_1.name, 'base': 100, 'amount': 21, 'tax_ids': []}, + {'name': self.percent_tax_1_incl.name, 'base': 100, 'amount': 21, 'tax_ids': []}, + {'name': self.percent_tax_2.name, 'base': 100, 'amount': 12, 'tax_ids': []}, + ]) + + def test_affecting_base_amount(self): + ''' Test: + price_unit | Taxes + ------------------ + 121 | 21% incl, 12% + 100 | 12% + + Expected: + Tax | Taxes | Base | Amount + -------------------------------------------- + 21% incl | / | 100 | 21 + 12% | 21% incl | 121 | 14.52 + 12% | / | 100 | 12 + ''' + invoice = self._create_invoice([ + (121, self.percent_tax_1_incl + self.percent_tax_2), + (100, self.percent_tax_2), + ]) + invoice.action_invoice_open() + self.assertRecordValues(invoice.tax_line_ids.sorted(lambda x: x.amount), [ + {'name': self.percent_tax_2.name, 'base': 100, 'amount': 12, 'tax_ids': []}, + {'name': self.percent_tax_2.name, 'base': 121, 'amount': 14.52, 'tax_ids': [self.percent_tax_1_incl.id]}, + {'name': self.percent_tax_1_incl.name, 'base': 100, 'amount': 21, 'tax_ids': []}, + ]) + + def test_group_of_taxes(self): + ''' Test: + price_unit | Taxes + ------------------ + 121 | 21% incl + 12% + 100 | 12% + + Expected: + Tax | Taxes | Base | Amount + -------------------------------------------- + 21% incl | / | 100 | 21 + 12% | 21% incl | 121 | 14.52 + 12% | / | 100 | 12 + ''' + invoice = self._create_invoice([ + (121, self.group_tax), + (100, self.percent_tax_2), + ]) + invoice.action_invoice_open() + self.assertRecordValues(invoice.tax_line_ids.sorted(lambda x: x.amount), [ + {'name': self.percent_tax_2.name, 'base': 100, 'amount': 12, 'tax_ids': []}, + {'name': self.percent_tax_2.name, 'base': 121, 'amount': 14.52, 'tax_ids': [self.percent_tax_1_incl.id]}, + {'name': self.percent_tax_1_incl.name, 'base': 100, 'amount': 21, 'tax_ids': []}, + ]) + + def _create_tax_tag(self, tag_name): + return self.env['account.account.tag'].create({ + 'name': tag_name, + 'applicability': 'taxes', + }) + + def test_tax_repartition(self): + inv_base_tag = self._create_tax_tag('invoice_base') + inv_tax_tag_10 = self._create_tax_tag('invoice_tax_10') + inv_tax_tag_90 = self._create_tax_tag('invoice_tax_90') + ref_base_tag = self._create_tax_tag('refund_base') + ref_tax_tag = self._create_tax_tag('refund_tax') + + user_type = self.env.ref('account.data_account_type_current_assets') + account_1 = self.env['account.account'].create({'name': 'test1', 'code': 'test1', 'user_type_id': user_type.id}) + account_2 = self.env['account.account'].create({'name': 'test2', 'code': 'test2', 'user_type_id': user_type.id}) + + tax = self.env['account.tax'].create({ + 'name': "Tax with account", + 'amount_type': 'fixed', + 'type_tax_use': 'sale', + 'amount': 42, + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(4, inv_base_tag.id, 0)], + }), + + (0,0, { + 'factor_percent': 10, + 'repartition_type': 'tax', + 'account_id': account_1.id, + 'tag_ids': [(4, inv_tax_tag_10.id, 0)], + }), + + (0,0, { + 'factor_percent': 90, + 'repartition_type': 'tax', + 'account_id': account_2.id, + 'tag_ids': [(4, inv_tax_tag_90.id, 0)], + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(4, ref_base_tag.id, 0)], + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': account_1.id, + 'tag_ids': [(4, ref_tax_tag.id, 0)], + }), + ], + }) + + # Test invoice repartition + invoice = self._create_invoice([(100, tax)], inv_type='out_invoice') + invoice.action_invoice_open() + invoice_move = invoice.move_id + + self.assertEqual(len(invoice_move.line_ids), 4, "There should be 4 account move lines created for the invoice: payable, base and 2 tax lines") + inv_base_line = invoice_move.line_ids.filtered(lambda x: not x.tax_repartition_line_id and x.account_id.user_type_id.type != 'receivable') + self.assertEqual(len(inv_base_line), 1, "There should be only one base line generated") + self.assertEqual(abs(inv_base_line.balance), 100, "Base amount should be 100") + self.assertEqual(inv_base_line.tag_ids, inv_base_tag, "Base line should have received base tag") + inv_tax_lines = invoice_move.line_ids.filtered(lambda x: x.tax_repartition_line_id.repartition_type == 'tax') + self.assertEqual(len(inv_tax_lines), 2, "There should be two tax lines, one for each repartition line.") + self.assertEqual(abs(inv_tax_lines.filtered(lambda x: x.account_id == account_1).balance), 4.2, "Tax line on account 1 should amount to 4.2 (10% of 42)") + self.assertEqual(inv_tax_lines.filtered(lambda x: x.account_id == account_1).tag_ids, inv_tax_tag_10, "Tax line on account 1 should have 10% tag") + self.assertEqual(abs(inv_tax_lines.filtered(lambda x: x.account_id == account_2).balance), 37.8, "Tax line on account 2 should amount to 37.8 (90% of 42)") + self.assertEqual(inv_tax_lines.filtered(lambda x: x.account_id == account_2).tag_ids, inv_tax_tag_90, "Tax line on account 2 should have 90% tag") + + # Test refund repartition + refund = self._create_invoice([(100, tax)], inv_type='out_refund') + refund.action_invoice_open() + refund_move = refund.move_id + + self.assertEqual(len(refund_move.line_ids), 3, "There should be 4 account move lines created for the refund: payable, base and tax line") + ref_base_line = refund_move.line_ids.filtered(lambda x: not x.tax_repartition_line_id and x.account_id.user_type_id.type != 'receivable') + self.assertEqual(len(ref_base_line), 1, "There should be only one base line generated") + self.assertEqual(abs(ref_base_line.balance), 100, "Base amount should be 100") + self.assertEqual(ref_base_line.tag_ids, ref_base_tag, "Base line should have received base tag") + ref_tax_line = refund_move.line_ids.filtered(lambda x: x.tax_repartition_line_id.repartition_type == 'tax') + self.assertEqual(len(ref_tax_line), 1, "There should be only one tax line") + self.assertEqual(ref_tax_line.account_id, account_1, "Tax line should have been made on account 1") + self.assertEqual(abs(ref_tax_line.balance), 42, "Tax line should have been made on account 1") + self.assertEqual(ref_tax_line.tag_ids, ref_tax_tag, "Tax line should have the right tag") diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index c3064a03b32..3e4e2f0650c 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -98,10 +98,33 @@ class TestReconciliation(AccountingTestCase): 'type_tax_use': 'purchase', 'company_id': company.id, 'amount': 20, - 'account_id': self.tax_waiting_account.id, 'tax_exigibility': 'on_payment', - 'cash_basis_account_id': self.tax_final_account.id, + 'cash_basis_transition_account_id': self.tax_waiting_account.id, 'cash_basis_base_account_id': self.tax_base_amount_account.id, + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': self.tax_final_account.id, + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': self.tax_final_account.id, + }), + ], }) def create_invoice(self, type='out_invoice', invoice_amount=50, currency_id=None): @@ -227,7 +250,7 @@ class TestReconciliationExec(TestReconciliation): {'debit': 10.74, 'credit': 0.0, 'account_id': self.diff_expense_account.id}, {'debit': 0.0, 'credit': 10.74, 'account_id': self.account_rcv.id}, ]) - + self.assertRecordValues(supplier_move_lines, [ {'debit': 0.0, 'credit': 27.47, 'amount_currency': -42, 'currency_id': self.currency_usd_id}, {'debit': 27.47, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_swiss_id}, @@ -249,7 +272,7 @@ class TestReconciliationExec(TestReconciliation): {'debit': 0.0, 'credit': 7.30, 'account_id': self.diff_income_account.id}, {'debit': 7.30, 'credit': 0.0, 'account_id': self.account_rcv.id}, ]) - + self.assertRecordValues(supplier_move_lines, [ {'debit': 0.0, 'credit': 40.0, 'amount_currency': -50, 'currency_id': self.currency_usd_id}, {'debit': 40.0, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_usd_id}, @@ -643,12 +666,12 @@ class TestReconciliationExec(TestReconciliation): 'currency_id': self.currency_usd_id, 'company_id': self.env.ref('base.main_company').id}) - self.env['res.currency.rate'].create({'name': time.strftime('%Y') + '-' + '08' + '-01', + self.env['res.currency.rate'].create({'name': time.strftime('%Y') + '-' + '08' + '-01', 'rate': 0.75, 'currency_id': self.currency_usd_id, 'company_id': self.env.ref('base.main_company').id}) - self.env['res.currency.rate'].create({'name': time.strftime('%Y') + '-' + '09' + '-01', + self.env['res.currency.rate'].create({'name': time.strftime('%Y') + '-' + '09' + '-01', 'rate': 0.80, 'currency_id': self.currency_usd_id, 'company_id': self.env.ref('base.main_company').id}) @@ -1280,7 +1303,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 16.67, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) purchase_move.post() @@ -1398,7 +1421,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': tax_waiting_account10.id, 'debit': 5, 'move_id': purchase_move.id, - 'tax_line_id': tax_cash_basis10percent.id, + 'tax_repartition_line_id': tax_cash_basis10percent.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) AccountMoveLine.create({ 'name': 'expenseTaxed 20%', @@ -1412,7 +1435,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 16.67, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) purchase_move.post() @@ -1520,18 +1543,18 @@ class TestReconciliationExec(TestReconciliation): expected['tax_10'] ) index += 1 - + def test_reconciliation_to_check(self): partner = self.env['res.partner'].create({'name': 'UncertainPartner'}) currency = self.env.company_id.currency_id invoice = self.create_invoice_partner(currency_id=currency.id, partner_id=partner.id) journal = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'THE', 'update_posted':True}) - + statement = self.make_payment(invoice, journal, 50) st_line = statement.line_ids previous_move_lines = st_line.journal_entry_ids.ids previous_name = st_line.move_name - + with self.assertRaises(UserError): #you need edition mode to be able to change it st_line.with_context(edition_mode=False).process_reconciliation( counterpart_aml_dicts=[], @@ -1542,7 +1565,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.diff_income_account.id }], ) - + st_line.with_context(edition_mode=True).process_reconciliation( counterpart_aml_dicts=[], new_aml_dicts = [{ @@ -1620,9 +1643,9 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 17094.66, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, 'currency_id': self.currency_usd_id, 'amount_currency': 848.16, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) purchase_payable_line0 = aml_obj.create({ 'name': 'Payable', @@ -1697,8 +1720,7 @@ class TestReconciliationExec(TestReconciliation): self.assertEqual(len(cash_basis_aml_ids), 4) # check amounts - cash_basis_move1 = cash_basis_moves.filtered( - lambda m: m.amount == 123936.31) + cash_basis_move1 = cash_basis_moves.filtered(lambda m: m.currency_id.is_zero(sum(line.credit for line in m.line_ids) - 123936.31)) self.assertTrue(cash_basis_move1.exists()) @@ -1793,7 +1815,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 17094.66, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, 'currency_id': self.currency_usd_id, 'amount_currency': 848.16, }) @@ -1893,8 +1915,7 @@ class TestReconciliationExec(TestReconciliation): self.assertEqual(len(cash_basis_aml_ids), 4) # check amounts - cash_basis_move1 = cash_basis_moves.filtered( - lambda m: m.amount == 123936.31) + cash_basis_move1 = cash_basis_moves.filtered(lambda m: m.currency_id.is_zero(sum(line.credit for line in m.line_ids) - 123936.31)) self.assertTrue(cash_basis_move1.exists()) @@ -1953,7 +1974,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': tax_waiting_account10.id, 'debit': 5, 'move_id': purchase_move.id, - 'tax_line_id': tax_cash_basis10percent.id, + 'tax_repartition_line_id': tax_cash_basis10percent.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) AccountMoveLine.create({ 'name': 'expenseTaxed 20%', @@ -1967,7 +1988,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 20, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) purchase_move.post() diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index 7a763b3482e..903699dc4d9 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -1,5 +1,5 @@ +# -*- coding: utf-8 -*- from odoo.addons.account.tests.account_test_users import AccountTestUsers -import time from odoo.tests import tagged @@ -27,6 +27,12 @@ class TestTax(AccountTestUsers): 'amount': 10, 'sequence': 3, }) + self.percent_tax_bis = self.tax_model.create({ + 'name': "Percent tax bis", + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 4, + }) self.division_tax = self.tax_model.create({ 'name': "Division tax", 'amount_type': 'division', @@ -73,56 +79,149 @@ class TestTax(AccountTestUsers): self.tax_with_account = self.tax_model.create({ 'name': "Tax with account", 'amount_type': 'fixed', - 'account_id': some_account.id, - 'refund_account_id': some_account.id, 'amount': 0, 'sequence': 8, + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': some_account.id, + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': some_account.id, + }), + ], }) self.bank_journal = self.env['account.journal'].search([('type', '=', 'bank'), ('company_id', '=', self.account_manager.company_id.id)])[0] self.bank_account = self.bank_journal.default_debit_account_id self.expense_account = self.env['account.account'].search([('user_type_id.type', '=', 'payable')], limit=1) #Should be done by onchange later + def _check_compute_all_results(self, total_included, total_excluded, taxes, res): + self.assertAlmostEqual(res['total_included'], total_included) + self.assertAlmostEqual(res['total_excluded'], total_excluded) + for i in range(0, len(taxes)): + self.assertAlmostEqual(res['taxes'][i]['base'], taxes[i][0]) + self.assertAlmostEqual(res['taxes'][i]['amount'], taxes[i][1]) + def test_tax_group_of_group_tax(self): self.fixed_tax.include_base_amount = True - self.group_tax.include_base_amount = True - self.group_of_group_tax.include_base_amount = True res = self.group_of_group_tax.compute_all(200.0) - self.assertEquals(res['total_excluded'], 200.0) - # After calculation of first group - # base = 210 - # total_included = 231 - # Base of the first grouped is passed - # Base after the second group (220) is dropped. - # Base of the group of groups is passed out, - # so we obtain base as after first group - self.assertEquals(res['base'], 210.0) - self.assertEquals(res['total_included'], 263.0) + self._check_compute_all_results( + 263, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + (210.0, 10.0), # | 1 | 10 | | t + (220.0, 22.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_group(self): res = self.group_tax.compute_all(200.0) - self.assertEquals(res['total_excluded'], 200.0) - self.assertEquals(res['total_included'], 230.0) - self.assertEquals(len(res['taxes']), 2) - self.assertEquals(res['taxes'][0]['amount'], 10.0) - self.assertEquals(res['taxes'][1]['amount'], 20.0) + self._check_compute_all_results( + 230, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_percent_division(self): self.division_tax.price_include = True self.division_tax.include_base_amount = True + res_division = self.division_tax.compute_all(200.0) + self._check_compute_all_results( + 200, # 'total_included' + 180, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (180.0, 20.0), # | 4 | 10/ | t | t + # --------------------------------------------------- + ], + res_division + ) self.percent_tax.price_include = False self.percent_tax.include_base_amount = False - res_division = self.division_tax.compute_all(200.0) - res_percent = self.percent_tax.compute_all(200.0) - self.assertEquals(res_division['taxes'][0]['amount'], 20.0) - self.assertEquals(res_percent['taxes'][0]['amount'], 20.0) + res_percent = self.percent_tax.compute_all(100.0) + self._check_compute_all_results( + 110, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res_percent + ) self.division_tax.price_include = False self.division_tax.include_base_amount = False + res_division = self.division_tax.compute_all(180.0) + self._check_compute_all_results( + 200, # 'total_included' + 180, # 'total_excluded' + [ + # base, amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (180.0, 20.0), # | 4 | 10/ | | + # --------------------------------------------------- + ], + res_division + ) self.percent_tax.price_include = True self.percent_tax.include_base_amount = True - res_division = self.division_tax.compute_all(200.0) - res_percent = self.percent_tax.compute_all(200.0) - self.assertEquals(res_division['taxes'][0]['amount'], 22.22) - self.assertEquals(res_percent['taxes'][0]['amount'], 18.18) + res_percent = self.percent_tax.compute_all(110.0) + self._check_compute_all_results( + 110, # 'total_included' + 100, # 'total_excluded' + [ + # base, amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 3 | 10% | t | t + # --------------------------------------------------- + ], + res_percent + ) + self.percent_tax_bis.price_include = True + self.percent_tax_bis.include_base_amount = True + self.percent_tax_bis.amount = 21 + res_percent = self.percent_tax_bis.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'total_included' + 5.79, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (5.79, 1.21), # | 3 | 21% | t | t + # --------------------------------------------------- + ], + res_percent + ) def test_tax_sequence_normalized_set(self): self.division_tax.sequence = 1 @@ -130,28 +229,166 @@ class TestTax(AccountTestUsers): self.percent_tax.sequence = 3 taxes_set = (self.group_tax | self.division_tax) res = taxes_set.compute_all(200.0) - self.assertEquals(res['taxes'][0]['amount'], 22.22) - self.assertEquals(res['taxes'][1]['amount'], 10.0) - self.assertEquals(res['taxes'][2]['amount'], 20.0) + self._check_compute_all_results( + 252.22, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 22.22), # | 1 | 10/ | | + (200.0, 10.0), # | 2 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) - def test_tax_include_base_amount(self): + def test_fixed_tax_include_base_amount(self): self.fixed_tax.include_base_amount = True res = self.group_tax.compute_all(200.0) - self.assertEquals(res['total_included'], 231.0) + self._check_compute_all_results( + 231, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) - def test_tax_currency(self): - self.division_tax.amount = 15.0 - res = self.division_tax.compute_all(200.0, currency=self.env.ref('base.VEF')) - self.assertAlmostEqual(res['total_included'], 235.2941) + self.fixed_tax.price_include = True + self.fixed_tax.include_base_amount = False + res = self.fixed_tax.compute_all(100.0, quantity=2.0) + self._check_compute_all_results( + 200, # 'total_included' + 180, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (180.0, 20.0), # | 1 | 20 | | t + # --------------------------------------------------- + ], + res + ) - def test_tax_with_no_account(self): - self.tax_with_no_account.amount = 10.0 - res = self.tax_with_no_account.compute_all(200.0) - self.assertEquals(res['total_included'], 210) - self.assertEquals(res['total_void'], 210) + def test_percent_tax_include_base_amount(self): + self.percent_tax.price_include = True + self.percent_tax.amount = 21.0 + res = self.percent_tax.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'total_included' + 5.79, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (5.79, 1.21), # | 3 | 21% | t | + # --------------------------------------------------- + ], + res + ) - def test_tax_with_account(self): - self.tax_with_account.amount = 10.0 - res = self.tax_with_account.compute_all(200.0) - self.assertEquals(res['total_included'], 210) - self.assertEquals(res['total_void'], 200) + self.percent_tax.price_include = True + self.percent_tax.amount = 20.0 + res = self.percent_tax.compute_all(399.99) + self._check_compute_all_results( + 399.99, # 'total_included' + 333.33, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (333.33, 66.66), # | 3 | 20% | t | + # --------------------------------------------------- + ], + res + ) + + def test_tax_decimals(self): + """Test the rounding of taxes up to 6 decimals (maximum decimals places allowed for currencies)""" + self.env.user.company_id.currency_id.rounding = 0.000001 + + self.percent_tax.price_include = True + self.percent_tax.amount = 21.0 + res = self.percent_tax.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'total_included' + 5.785124, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # -------------------------------------------------------- + (5.785124, 1.214876), # | 3 | 21% | t | + # -------------------------------------------------------- + ], + res + ) + + self.percent_tax.price_include = True + self.percent_tax.amount = 20.0 + res = self.percent_tax.compute_all(399.999999) + self._check_compute_all_results( + 399.999999, # 'total_included' + 333.333333, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # ----------------------------------------------------------- + (333.333333, 66.666666), # | 3 | 20% | t | + # ----------------------------------------------------------- + ], + res + ) + + def test_advanced_taxes_computation_0(self): + '''Test more advanced taxes computation (see issue 34471).''' + tax_1 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_1', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'include_base_amount': True, + 'sequence': 1, + }) + tax_2 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_2', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 2, + }) + tax_3 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_3', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 3, + }) + tax_4 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_4', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 4, + }) + tax_5 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_5', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 5, + }) + taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5 + res = taxes.compute_all(132.0) + self._check_compute_all_results( + 154, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 1 | 10% | t | t + (110.0, 11.0), # | 2 | 10% | | + (110.0, 11.0), # | 3 | 10% | t | + (110.0, 11.0), # | 4 | 10% | | + (110.0, 11.0), # | 5 | 10% | t | + # --------------------------------------------------- + ], + res + ) diff --git a/addons/account/tests/test_templates_consistency.py b/addons/account/tests/test_templates_consistency.py index 015eb5f46ad..eb6219a5446 100644 --- a/addons/account/tests/test_templates_consistency.py +++ b/addons/account/tests/test_templates_consistency.py @@ -41,6 +41,8 @@ class AccountingTestTemplConsistency(HttpCase): ''' self.check_fields_consistency('account.tax.template', 'account.tax', exceptions=['chart_template_id']) self.check_fields_consistency('account.tax', 'account.tax.template', exceptions=['company_id']) + self.check_fields_consistency('account.tax.repartition.line.template', 'account.tax.repartition.line', exceptions=['plus_report_line_ids', 'minus_report_line_ids']) + self.check_fields_consistency('account.tax.repartition.line', 'account.tax.repartition.line.template', exceptions=['tag_ids', 'country_id', 'company_id', 'sequence']) def test_fiscal_position_fields(self): '''Test fields consistency for ('account.fiscal.position', 'account.fiscal.position.template') diff --git a/addons/account/views/account.xml b/addons/account/views/account.xml index 57539d4f6a1..837d27235b3 100644 --- a/addons/account/views/account.xml +++ b/addons/account/views/account.xml @@ -15,6 +15,7 @@ + @@ -22,6 +23,7 @@ + diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index f21c42d355b..e633c71a3ef 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -140,6 +140,7 @@ + @@ -159,6 +160,7 @@ + @@ -181,8 +183,8 @@ - - + + @@ -365,10 +367,11 @@
- + + @@ -378,6 +381,8 @@ + +
@@ -634,6 +639,7 @@ + @@ -643,6 +649,8 @@ + + diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml index 354f33fd15f..32083dc6ac8 100644 --- a/addons/account/views/account_journal_dashboard_view.xml +++ b/addons/account/views/account_journal_dashboard_view.xml @@ -10,6 +10,8 @@ + +
@@ -20,12 +22,15 @@
- -
-
+
+ +
@@ -271,7 +276,7 @@
@@ -324,6 +329,16 @@
+ +
+ +
+ +
+
+
Sample data diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 98c4af96b58..92a8df28af7 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -43,6 +43,7 @@ + @@ -365,14 +367,15 @@ + + - - + @@ -458,7 +461,7 @@ view_id="account.view_move_line_tax_audit_tree"/> - {'journal_type':'general', 'search_default_group_by_move': 1} + {'journal_type':'general', 'search_default_group_by_move': 1, 'search_default_posted':1} Journal Items account.move.line @@ -468,7 +471,7 @@ Journal Items account.move.line - {'search_default_partner_id': [active_id], 'default_partner_id': active_id} + {'search_default_partner_id': [active_id], 'default_partner_id': active_id, 'search_default_posted':1} diff --git a/addons/account/views/account_portal_templates.xml b/addons/account/views/account_portal_templates.xml index 4123da601d1..249a2dd93be 100644 --- a/addons/account/views/account_portal_templates.xml +++ b/addons/account/views/account_portal_templates.xml @@ -83,7 +83,7 @@
- +

diff --git a/addons/account/views/account_tax_report_views.xml b/addons/account/views/account_tax_report_views.xml new file mode 100644 index 00000000000..1860df8cc08 --- /dev/null +++ b/addons/account/views/account_tax_report_views.xml @@ -0,0 +1,76 @@ + + + + + + account.tax.report.line.tree + account.tax.report.line + + + + + + + + + + account.tax.report.line.form + account.tax.report.line + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + Configure Tax Report + ir.actions.act_window + account.tax.report.line + form + tree,form + + + + + + +
+
\ No newline at end of file diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 33e1a5c9b0c..965744f4c3b 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -306,11 +306,11 @@ -
- - - -
+
+ + + +
@@ -328,100 +328,16 @@ - - account.bank.journal.tree - account.journal - 10 - - - - - - - + + Add a Bank Account + + code + +action = model.setting_init_bank_account_action() - - account.bank.journal.kanban - account.journal - 10 - - - - -
-
-
- -
-
-
-
-
-
-
-
- - account.bank.journal.form - account.journal - 10 - -
- - -
-
- - - - - - - - - - - - - -
- - - - -
- - - -
-
-
-
-
- - Bank Accounts - account.journal - tree,kanban,form - - [('type', '=', 'bank')] - {'default_type': 'bank'} - -

- Create a bank account -

-
-
- + Journals @@ -477,6 +393,10 @@

+ + + + @@ -1072,6 +992,22 @@
+ + account.tax.repartition.line.tree + account.tax.repartition.line + + + + + + + + + + + + + account.invoice.line.tax.search account.tax @@ -1151,35 +1087,34 @@ + + - - - - @@ -1188,7 +1123,6 @@ - @@ -1197,8 +1131,8 @@ - +
@@ -1223,6 +1157,29 @@ + + + account.tax.group.tree + account.tax.group + + + + + + + + + + + + Tax Groups + account.tax.group + form + tree + + + + account.full.reconcile.form @@ -1521,10 +1478,6 @@ %
- - - - - - + + diff --git a/addons/account/views/report_payment_receipt_templates.xml b/addons/account/views/report_payment_receipt_templates.xml index 871ae39479f..c0e11a2ef7f 100644 --- a/addons/account/views/report_payment_receipt_templates.xml +++ b/addons/account/views/report_payment_receipt_templates.xml @@ -46,7 +46,7 @@ - + diff --git a/addons/account/views/tax_adjustments.xml b/addons/account/views/tax_adjustments.xml index fb3db8e2648..2167f52d0d8 100644 --- a/addons/account/views/tax_adjustments.xml +++ b/addons/account/views/tax_adjustments.xml @@ -7,6 +7,5 @@ form new
- - + diff --git a/addons/account/wizard/account_invoice_refund.py b/addons/account/wizard/account_invoice_refund.py index 03c94b37d95..7f3ab878404 100644 --- a/addons/account/wizard/account_invoice_refund.py +++ b/addons/account/wizard/account_invoice_refund.py @@ -24,7 +24,7 @@ class AccountInvoiceRefund(models.TransientModel): date = fields.Date(string='Accounting Date') description = fields.Char(string='Reason', required=True, default=lambda self: self._get_reason()) refund_only = fields.Boolean(string='Technical field to hide filter_refund in case invoice is partially paid', compute='_get_refund_only') - filter_refund = fields.Selection([('refund', 'Create a draft credit note'), ('cancel', 'Cancel: create credit note and reconcile'), ('modify', 'Modify: create credit note, reconcile and create a new draft invoice')], + filter_refund = fields.Selection([('refund', 'Create a draft credit note (partial refunding)'), ('cancel', 'Cancel: create credit note and reconcile (full refunding)'), ('modify', 'Modify: create credit note, reconcile and create a new draft invoice')], default='refund', string='Credit Method', required=True, help='Choose how you want to credit this invoice. You cannot Modify and Cancel if the invoice is already reconciled') @api.depends('date_invoice') diff --git a/addons/account/wizard/wizard_tax_adjustments.py b/addons/account/wizard/wizard_tax_adjustments.py index 2ebae475fe4..af573d9fef5 100644 --- a/addons/account/wizard/wizard_tax_adjustments.py +++ b/addons/account/wizard/wizard_tax_adjustments.py @@ -25,25 +25,36 @@ class TaxAdjustments(models.TransientModel): @api.multi def _create_move(self): adjustment_type = self.env.context.get('adjustment_type', (self.amount > 0.0 and 'debit' or 'credit')) - debit_vals = { + move_line_vals = [] + + is_debit = adjustment_type == 'debit' + for tax_vals in self.tax_id.compute_all(abs(self.amount))['taxes']: + repartition_line = self.env['account.tax.repartition.line'].browse(tax_vals['tax_repartition_line_id']) + # Vals for the amls corresponding to the tax + move_line_vals.append((0, 0, { + 'name': self.reason, + 'debit': is_debit and abs(self.amount) or 0, + 'credit': not is_debit and abs(self.amount) or 0, + 'account_id': is_debit and self.debit_account_id.id or self.credit_account_id.id, + 'tax_repartition_line_id': repartition_line.id, + 'tag_ids': [(6, False, repartition_line.tag_ids.ids)], + })) + + # Vals for the counterpart line + move_line_vals.append((0, 0, { 'name': self.reason, - 'debit': abs(self.amount), - 'credit': 0.0, - 'account_id': self.debit_account_id.id, - 'tax_line_id': adjustment_type == 'debit' and self.tax_id.id or False, - } - credit_vals = { - 'name': self.reason, - 'debit': 0.0, - 'credit': abs(self.amount), - 'account_id': self.credit_account_id.id, - 'tax_line_id': adjustment_type == 'credit' and self.tax_id.id or False, - } + 'debit': not is_debit and abs(self.amount) or 0, + 'credit': is_debit and abs(self.amount) or 0, + 'account_id': is_debit and self.credit_account_id.id or self.debit_account_id.id, + 'tax_repartition_line_id': False, + })) + + # Create the move vals = { 'journal_id': self.journal_id.id, 'date': self.date, 'state': 'draft', - 'line_ids': [(0, 0, debit_vals), (0, 0, credit_vals)] + 'line_ids': move_line_vals, } move = self.env['account.move'].create(vals) move.post() diff --git a/addons/account_cancel/views/account_views.xml b/addons/account_cancel/views/account_views.xml index e3dd3821294..18f308bd9c5 100644 --- a/addons/account_cancel/views/account_views.xml +++ b/addons/account_cancel/views/account_views.xml @@ -17,7 +17,7 @@ - +

diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py index 0f637b507e1..36cb3867ba5 100644 --- a/addons/stock_account/models/product.py +++ b/addons/stock_account/models/product.py @@ -115,8 +115,11 @@ class ProductProduct(models.Model): def _get_fifo_candidates_in_move(self): """ Find IN moves that can be used to value OUT moves. """ + return self._get_fifo_candidates_in_move_with_company() + + def _get_fifo_candidates_in_move_with_company(self, move_company_id=False): self.ensure_one() - domain = [('product_id', '=', self.id), ('remaining_qty', '>', 0.0)] + self.env['stock.move']._get_in_base_domain() + domain = [('product_id', '=', self.id), ('remaining_qty', '>', 0.0)] + self.env['stock.move']._get_in_base_domain(move_company_id) candidates = self.env['stock.move'].search(domain, order='date, id') return candidates @@ -158,18 +161,30 @@ class ProductProduct(models.Model): for row in res: fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4])) - product_values = {product: 0 for product in self} - product_move_ids = {product: [] for product in self} + product_values = {product.id: 0 for product in self} + product_move_ids = {product.id: [] for product in self} + if to_date: domain = [('product_id', 'in', self.ids), ('date', '<=', to_date)] + StockMove._get_all_base_domain() - for move in StockMove.search(domain).with_context(prefetch_fields=False): - product_values[move.product_id] += move.value - product_move_ids[move.product_id].append(move.id) + value_field_name = 'value' else: domain = [('product_id', 'in', self.ids)] + StockMove._get_all_base_domain() - for move in StockMove.search(domain).with_context(prefetch_fields=False): - product_values[move.product_id] += move.remaining_value - product_move_ids[move.product_id].append(move.id) + value_field_name = 'remaining_value' + + StockMove.check_access_rights('read') + query = StockMove._where_calc(domain) + StockMove._apply_ir_rules(query, 'read') + from_clause, where_clause, params = query.get_sql() + query_str = """ + SELECT stock_move.product_id, SUM(COALESCE(stock_move.{}, 0.0)), ARRAY_AGG(stock_move.id) + FROM {} + WHERE {} + GROUP BY stock_move.product_id + """.format(value_field_name, from_clause, where_clause) + self.env.cr.execute(query_str, params) + for product_id, value, move_ids in self.env.cr.fetchall(): + product_values[product_id] = value + product_move_ids[product_id] = move_ids for product in self: if product.cost_method in ['standard', 'average']: @@ -185,9 +200,9 @@ class ProductProduct(models.Model): elif product.cost_method == 'fifo': if to_date: if product.product_tmpl_id.valuation == 'manual_periodic': - product.stock_value = product_values[product] + product.stock_value = product_values[product.id] product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available - product.stock_fifo_manual_move_ids = StockMove.browse(product_move_ids[product]) + product.stock_fifo_manual_move_ids = StockMove.browse(product_move_ids[product.id]) elif product.product_tmpl_id.valuation == 'real_time': valuation_account_id = product.categ_id.property_stock_valuation_account_id.id value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, []) @@ -195,10 +210,10 @@ class ProductProduct(models.Model): product.qty_at_date = quantity product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids) else: - product.stock_value, moves = product_values[product], StockMove.browse(product_move_ids[product]) + product.stock_value = product_values[product.id] product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available if product.product_tmpl_id.valuation == 'manual_periodic': - product.stock_fifo_manual_move_ids = moves + product.stock_fifo_manual_move_ids = StockMove.browse(product_move_ids[product.id]) elif product.product_tmpl_id.valuation == 'real_time': valuation_account_id = product.categ_id.property_stock_valuation_account_id.id value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, []) diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index aa8267cb5fd..2b462600d79 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -266,7 +266,7 @@ class StockMove(models.Model): # Find back incoming stock moves (called candidates here) to value this move. qty_to_take_on_candidates = quantity or valued_quantity - candidates = move.product_id._get_fifo_candidates_in_move() + candidates = move.product_id._get_fifo_candidates_in_move_with_company(move.company_id.id) new_standard_price = 0 tmp_value = 0 # to accumulate the value taken on the candidates for candidate in candidates: @@ -320,6 +320,7 @@ class StockMove(models.Model): def _run_valuation(self, quantity=None): self.ensure_one() + value_to_return = 0 if self._is_in(): valued_move_lines = self.move_line_ids.filtered(lambda ml: not ml.location_id._should_be_valued() and ml.location_dest_id._should_be_valued() and not ml.owner_id) valued_quantity = 0 @@ -377,7 +378,7 @@ class StockMove(models.Model): 'value': value_to_return, 'price_unit': price_unit if self._is_dropshipped() else -price_unit, }) - return value_to_return + return value_to_return def _action_done(self, cancel_backorder=False): self.product_price_update_before_done() diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py index 17a797aee69..7dd2b71ad75 100644 --- a/addons/stock_account/tests/test_stockvaluation.py +++ b/addons/stock_account/tests/test_stockvaluation.py @@ -5,69 +5,70 @@ from datetime import timedelta from odoo.exceptions import UserError from odoo.fields import Datetime -from odoo.tests.common import Form, TransactionCase +from odoo.tests.common import Form, SavepointCase -class TestStockValuation(TransactionCase): - def setUp(self): - super(TestStockValuation, self).setUp() - self.stock_location = self.env.ref('stock.stock_location_stock') - self.customer_location = self.env.ref('stock.stock_location_customers') - self.supplier_location = self.env.ref('stock.stock_location_suppliers') - self.partner = self.env['res.partner'].create({'name': 'xxx'}) - self.owner1 = self.env['res.partner'].create({'name': 'owner1'}) - self.uom_unit = self.env.ref('uom.product_uom_unit') - self.product1 = self.env['product.product'].create({ +class TestStockValuation(SavepointCase): + @classmethod + def setUpClass(cls): + super(TestStockValuation, cls).setUpClass() + cls.stock_location = cls.env.ref('stock.stock_location_stock') + cls.customer_location = cls.env.ref('stock.stock_location_customers') + cls.supplier_location = cls.env.ref('stock.stock_location_suppliers') + cls.partner = cls.env['res.partner'].create({'name': 'xxx'}) + cls.owner1 = cls.env['res.partner'].create({'name': 'owner1'}) + cls.uom_unit = cls.env.ref('uom.product_uom_unit') + cls.product1 = cls.env['product.product'].create({ 'name': 'Product A', 'type': 'product', 'default_code': 'prda', - 'categ_id': self.env.ref('product.product_category_all').id, + 'categ_id': cls.env.ref('product.product_category_all').id, }) - self.product2 = self.env['product.product'].create({ + cls.product2 = cls.env['product.product'].create({ 'name': 'Product B', 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, + 'categ_id': cls.env.ref('product.product_category_all').id, }) - self.product1.categ_id.property_valuation = 'real_time' - self.product2.categ_id.property_valuation = 'real_time' - Account = self.env['account.account'] - self.stock_input_account = Account.create({ + cls.product1.categ_id.property_valuation = 'real_time' + cls.product2.categ_id.property_valuation = 'real_time' + Account = cls.env['account.account'] + cls.stock_input_account = Account.create({ 'name': 'Stock Input', 'code': 'StockIn', - 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, }) - self.stock_output_account = Account.create({ + cls.stock_output_account = Account.create({ 'name': 'Stock Output', 'code': 'StockOut', - 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, }) - self.stock_valuation_account = Account.create({ + cls.stock_valuation_account = Account.create({ 'name': 'Stock Valuation', 'code': 'Stock Valuation', - 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, }) - self.expense_account = Account.create({ + cls.expense_account = Account.create({ 'name': 'Expense Account', 'code': 'Expense Account', - 'user_type_id': self.env.ref('account.data_account_type_expenses').id, + 'user_type_id': cls.env.ref('account.data_account_type_expenses').id, }) - self.stock_journal = self.env['account.journal'].create({ + cls.stock_journal = cls.env['account.journal'].create({ 'name': 'Stock Journal', 'code': 'STJTEST', 'type': 'general', }) - self.product1.categ_id.write({ - 'property_stock_account_input_categ_id': self.stock_input_account.id, - 'property_stock_account_output_categ_id': self.stock_output_account.id, - 'property_stock_valuation_account_id': self.stock_valuation_account.id, - 'property_stock_journal': self.stock_journal.id, + cls.product1.categ_id.write({ + 'property_stock_account_input_categ_id': cls.stock_input_account.id, + 'property_stock_account_output_categ_id': cls.stock_output_account.id, + 'property_stock_valuation_account_id': cls.stock_valuation_account.id, + 'property_stock_journal': cls.stock_journal.id, }) - self.product1.categ_id.write({ - 'property_stock_account_input_categ_id': self.stock_input_account.id, - 'property_stock_account_output_categ_id': self.stock_output_account.id, - 'property_stock_valuation_account_id': self.stock_valuation_account.id, - 'property_stock_journal': self.stock_journal.id, + cls.product1.categ_id.write({ + 'property_stock_account_input_categ_id': cls.stock_input_account.id, + 'property_stock_account_output_categ_id': cls.stock_output_account.id, + 'property_stock_valuation_account_id': cls.stock_valuation_account.id, + 'property_stock_journal': cls.stock_journal.id, }) def _get_stock_input_move_lines(self): diff --git a/addons/stock_dropshipping/models/purchase.py b/addons/stock_dropshipping/models/purchase.py index 39196cd20bf..7fee03bc250 100644 --- a/addons/stock_dropshipping/models/purchase.py +++ b/addons/stock_dropshipping/models/purchase.py @@ -14,12 +14,12 @@ class PurchaseOrderLine(models.Model): re['sale_line_id'] = self.sale_line_id.id return res - def _merge_in_existing_line(self, product_id, product_qty, product_uom, location_id, name, origin, values): - if values.get('route_ids') and values['route_ids'] == self.env.ref('stock_dropshipping.route_drop_shipping'): - return False - return super(PurchaseOrderLine, self)._merge_in_existing_line( - product_id=product_id, product_qty=product_qty, product_uom=product_uom, - location_id=location_id, name=name, origin=origin, values=values) + def _find_candidate(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values): + # if this is defined, this is a dropshipping line, so no + # this is to correctly map delivered quantities to the so lines + lines = self.filtered(lambda po_line: po_line.sale_line_id.id == values['sale_line_id']) if values.get('sale_line_id') else self + return super(PurchaseOrderLine, lines)._find_candidate(product_id, product_qty, product_uom, location_id, name, origin, company_id, values) + class StockRule(models.Model): _inherit = 'stock.rule' diff --git a/addons/stock_dropshipping/tests/test_dropship.py b/addons/stock_dropshipping/tests/test_dropship.py index 9837d93f0af..f70897f9eb8 100644 --- a/addons/stock_dropshipping/tests/test_dropship.py +++ b/addons/stock_dropshipping/tests/test_dropship.py @@ -48,8 +48,21 @@ class TestDropship(common.TransactionCase): so.order_line.product_uom_qty = 2.00 self.assertAlmostEqual(po_line.product_qty, 2.00) - -class TestDropship(common.TransactionCase): + # Create a new so line + sol2 = self.env['sale.order.line'].create({ + 'order_id': so.id, + 'name': prod.name, + 'product_id': prod.id, + 'product_uom_qty': 3.00, + 'product_uom': prod.uom_id.id, + 'price_unit': 12, + }) + # there is a new line + pol2 = po.order_line - po_line + # the first line is unchanged + self.assertAlmostEqual(po_line.product_qty, 2.00) + # the new line matches the new line on the so + self.assertAlmostEqual(pol2.product_qty, sol2.product_uom_qty) def test_00_dropship(self): diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index 3f6410c6899..1627dfd7eeb 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -239,7 +239,8 @@ class Survey(models.Model): """ Ensure conditions to create new tokens are met. """ self.ensure_one() if test_entry: - if not user.has_group('survey.group_survey_manager') or not user.has_group('survey.group_survey_user'): + # the current user must have the access rights to survey + if not user.has_group('survey.group_survey_user'): raise UserError(_('Creating test token is not allowed for you.')) else: if not self.active: diff --git a/addons/survey/views/assets.xml b/addons/survey/views/assets.xml index be87a96529f..6b171b74ebd 100644 --- a/addons/survey/views/assets.xml +++ b/addons/survey/views/assets.xml @@ -4,10 +4,7 @@ + + diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml index 48bcd2ff52a..886aebdc87d 100644 --- a/addons/survey/views/survey_templates.xml +++ b/addons/survey/views/survey_templates.xml @@ -123,7 +123,7 @@ diff --git a/addons/theme_bootswatch/static/lib/bootswatch/cyborg/_variables.scss b/addons/theme_bootswatch/static/lib/bootswatch/cyborg/_variables.scss index fda2a4bcc74..dc354a087ac 100644 --- a/addons/theme_bootswatch/static/lib/bootswatch/cyborg/_variables.scss +++ b/addons/theme_bootswatch/static/lib/bootswatch/cyborg/_variables.scss @@ -45,7 +45,7 @@ $body-color: $gray-500 !default; // Fonts -$font-family-sans-serif: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !default; +$font-family-sans-serif: "Roboto", "Noto", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !default; $font-size-base: .875rem !default; diff --git a/addons/transifex/models/ir_translation.py b/addons/transifex/models/ir_translation.py index c19f66c39c1..51edae39206 100644 --- a/addons/transifex/models/ir_translation.py +++ b/addons/transifex/models/ir_translation.py @@ -84,8 +84,7 @@ class IrTranslation(models.Model): 'project': project, 'lang': lang_code, 'module': translation.module, - 'src': werkzeug.url_quote_plus( - "text:'" + - translation.source[:50].replace("'", "\\'") + - "'"), + 'src': "text:'" + werkzeug.url_quote_plus( + translation.source[:50].replace("\n", "").replace("'", "") + ) + "'", } diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index a14a02ebb9b..b6bca60fedd 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -41,7 +41,7 @@ from odoo.tools.mimetypes import guess_mimetype from odoo.tools.translate import _ from odoo.tools.misc import str2bool, xlwt, file_open from odoo.tools.safe_eval import safe_eval -from odoo import http +from odoo import http, tools from odoo.http import content_disposition, dispatch_rpc, request, \ serialize_exception as _serialize_exception, Response from odoo.exceptions import AccessError, UserError, AccessDenied @@ -968,8 +968,7 @@ class View(http.Controller): class Binary(http.Controller): def placeholder(self, image='placeholder.png'): - addons_path = http.addons_manifest['web']['addons_path'] - return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read() + return tools.file_open(get_resource_path('web', 'static/src/img', image), 'rb').read() @http.route(['/web/content', '/web/content/', @@ -999,6 +998,15 @@ class Binary(http.Controller): response.set_cookie('fileToken', token) return response + @http.route(['/web/partner_image', + '/web/partner_image/', + '/web/partner_image//', + '/web/partner_image////'], type='http', auth="public") + def content_image_partner(self, rec_id, field='image_small', model='res.partner', **kwargs): + # other kwargs are ignored on purpose + return self._content_image(id=rec_id, model='res.partner', field=field, + placeholder='user_placeholder.jpg') + @http.route(['/web/image', '/web/image/', '/web/image//', @@ -1019,7 +1027,16 @@ class Binary(http.Controller): def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas', filename_field='datas_fname', unique=None, filename=None, mimetype=None, download=None, width=0, height=0, crop=False, access_token=None, - placeholder='placeholder.png', **kwargs): + **kwargs): + # other kwargs are ignored on purpose + return self._content_image(xmlid=xmlid, model=model, id=id, field=field, + filename_field=filename_field, unique=unique, filename=filename, mimetype=mimetype, + download=download, width=width, height=height, crop=crop, access_token=access_token) + + def _content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas', + filename_field='datas_fname', unique=None, filename=None, mimetype=None, + download=None, width=0, height=0, crop=False, access_token=None, + placeholder='placeholder.png', **kwargs): status, headers, image_base64 = request.env['ir.http'].binary_content( xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, diff --git a/addons/web/doc/module.rst b/addons/web/doc/module.rst index ffbe90d0933..ca808254567 100644 --- a/addons/web/doc/module.rst +++ b/addons/web/doc/module.rst @@ -412,7 +412,7 @@ responses": .. note:: - By defaut, tests cases don't load templates either. We had not + By default, tests cases don't load templates either. We had not needed to perform any template rendering before here, so we must now enable templates loading via :js:attr:`the corresponding option `. diff --git a/addons/web/static/lib/tempusdominus/tempusdominus.js b/addons/web/static/lib/tempusdominus/tempusdominus.js index ffca0c8e632..4eaf0df2a60 100644 --- a/addons/web/static/lib/tempusdominus/tempusdominus.js +++ b/addons/web/static/lib/tempusdominus/tempusdominus.js @@ -2754,7 +2754,8 @@ var TempusDominusBootstrap4 = function ($) { if ($target.length === 0) { return; } - if (config._options.debug || window.debug) { + // /!\ ODOO FIX: check on 'config' existence added by odoo + if (config && config._options.debug || window.debug) { return; } TempusDominusBootstrap4._jQueryInterface.call($target, 'hide', event); diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 9566dbda850..6b1a6249962 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -33,7 +33,9 @@ var qweb = core.qweb; var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMixin, { dependencies: ['notification'], - events: _.extend(KeyboardNavigationMixin.events, {}), + events: _.extend({}, KeyboardNavigationMixin.events, { + 'click .o_search_options .dropdown-menu': '_onClickDropDownMenu', + }), custom_events: { clear_uncommitted_changes: function (e) { this.clear_uncommitted_changes().then(e.data.callback); @@ -355,6 +357,16 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi // Handlers //-------------------------------------------------------------------------- + /** + * When clicking inside a dropdown to modify search options + * prevents the bootstrap dropdown to close on itself + * + * @private + * @param {Event} ev + */ + _onClickDropDownMenu: function (ev) { + ev.stopPropagation(); + }, /** * Whenever the connection is lost, we need to notify the user. * diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js index 81211788246..7be17700f98 100644 --- a/addons/web/static/src/js/core/dom.js +++ b/addons/web/static/src/js/core/dom.js @@ -153,10 +153,27 @@ var dom = { * * @param {jQuery} $from - the jQuery element(s) from which to search * @param {string} selector - the CSS selector to match + * @param {boolean} [addBack=false] - whether or not the $from element + * should be considered in the results * @returns {jQuery} */ - cssFind: function ($from, selector) { - return $from.find('*').filter(selector); + cssFind: function ($from, selector, addBack) { + var $results; + + // No way to correctly parse a complex jQuery selector but having no + // spaces should be a good-enough condition to use a simple find + var multiParts = selector.indexOf(' ') >= 0; + if (multiParts) { + $results = $from.find('*').filter(selector); + } else { + $results = $from.find(selector); + } + + if (addBack && $from.is(selector)) { + $results = $results.add($from); + } + + return $results; }, /** * Detaches widgets from the DOM and performs their on_detach_callback() diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 2330307b7a9..713ade2a989 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -808,7 +808,7 @@ var FieldBoolean = AbstractField.extend({ * @returns {jQuery} the focusable checkbox input */ getFocusableElement: function () { - return this.$input || $(); + return this.mode === 'readonly' ? $() : this.$input; }, /** * A boolean field is always set since false is a valid value. @@ -1042,7 +1042,13 @@ var FieldFloatToggle = AbstractField.extend({ * @returns {float} The current formatted value */ _getDisplayedValue: function () { - return parseFloat(this._formatValue(this.value)); + // this.value is a plain float + // Matches what is in Database + var usrFormatValue = this._formatValue(this.value); + // usrFormatValue is string + // contains a float represented in a user specific format + // the float is the fraction by [this.factor] of this.value + return field_utils.parse['float'](usrFormatValue); }, /** * Formats the HTML input tag for edit mode and stores selection status. @@ -1069,7 +1075,7 @@ var FieldFloatToggle = AbstractField.extend({ * one is not in the range, the next value of the closest one will be chosen. * * @private - * @returns {number} The next formatted value in the range + * @returns {number} The next value in the range */ _nextValue: function () { var range = this.nodeOptions.range; @@ -1098,7 +1104,8 @@ var FieldFloatToggle = AbstractField.extend({ if (this.mode === 'edit') { ev.stopPropagation(); // only stop propagation in edit mode var next_val = this._nextValue(); - this._setValue(next_val.toString()); // will be parsed in _setValue + next_val = field_utils.format['float'](next_val); + this._setValue(next_val); // will be parsed in _setValue } }, diff --git a/addons/web/static/src/js/libs/bootstrap.js b/addons/web/static/src/js/libs/bootstrap.js index 9db99815009..b91728b22d1 100644 --- a/addons/web/static/src/js/libs/bootstrap.js +++ b/addons/web/static/src/js/libs/bootstrap.js @@ -6,7 +6,7 @@ odoo.define('web.bootstrap.extensions', function () { * patching in place. */ -/* Bootstrap defaults overwrite */ +/* Bootstrap tooltip defaults overwrite */ $.fn.tooltip.Constructor.Default.placement = 'auto'; $.fn.tooltip.Constructor.Default.fallbackPlacement = ['bottom', 'right', 'left', 'top']; $.fn.tooltip.Constructor.Default.html = true; @@ -24,4 +24,8 @@ $.fn.tooltip.Constructor.prototype.show = function () { return bootstrapShowFunction.call(this); }; + +/* Bootstrap popover defaults overwrite */ +$.fn.popover.Constructor.Default.sanitize = false; + }); diff --git a/addons/web/static/src/js/public/public_root.js b/addons/web/static/src/js/public/public_root.js index 2c91c795df3..31b39e97abb 100644 --- a/addons/web/static/src/js/public/public_root.js +++ b/addons/web/static/src/js/public/public_root.js @@ -2,6 +2,7 @@ odoo.define('web.public.root', function (require) { 'use strict'; var ajax = require('web.ajax'); +var dom = require('web.dom'); var ServiceProviderMixin = require('web.ServiceProviderMixin'); var session = require('web.session'); var utils = require('web.utils'); @@ -199,7 +200,7 @@ var PublicRoot = publicWidget.RootWidget.extend(ServiceProviderMixin, { var defs = _.map(this._getPublicWidgetsRegistry(options), function (PublicWidget) { var selector = PublicWidget.prototype.selector || ''; - var $target = $from.find(selector).addBack(selector); + var $target = dom.cssFind($from, selector, true); var defs = _.map($target, function (el) { var widget = new PublicWidget(self, options); @@ -293,8 +294,8 @@ var PublicRoot = publicWidget.RootWidget.extend(ServiceProviderMixin, { var $buttons = $(ev.currentTarget).find('button[type="submit"], a.a-submit'); _.each($buttons, function (btn) { var $btn = $(btn); - $btn.attr('data-loading-text', ' ' + $(btn).text()); - $btn.button('loading'); + $btn.html(' ' + $btn.text()); + $btn.prop('disabled', true); }); }, /** diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js index 54e98bbb87e..e5eff8d7fc1 100644 --- a/addons/web/static/src/js/tools/debug_manager.js +++ b/addons/web/static/src/js/tools/debug_manager.js @@ -436,6 +436,7 @@ DebugManager.include({ var fields = state.fields; var fieldsInfo = state.fieldsInfo.form; var fieldNamesInView = state.getFieldNames(); + var fieldNamesOnlyOnView = ['message_attachment_count']; var fieldsValues = state.data; var modifierDatas = {}; _.each(fieldNamesInView, function (fieldName) { @@ -444,6 +445,7 @@ DebugManager.include({ }); }); this.fields = _.chain(fieldNamesInView) + .difference(fieldNamesOnlyOnView) .map(function (fieldName) { var modifierData = modifierDatas[fieldName]; var invisibleOrReadOnly; diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index c7a80864a15..607f49c8031 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -495,7 +495,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { additional_context: _.extend({}, data.context) }); } - return false; }, /** * FIXME: this logic should be rethought diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 6aa884bbacf..c87a6aca455 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -564,9 +564,16 @@ var BasicModel = AbstractModel.extend({ } } else if (field.type === 'one2many' || field.type === 'many2many') { if (options.raw) { - relDataPoint = this.localData[data[fieldName]]; - relDataPoint = this._applyX2ManyOperations(relDataPoint); - data[fieldName] = relDataPoint.res_ids; + if (typeof data[fieldName] === 'string') { + relDataPoint = this.localData[data[fieldName]]; + relDataPoint = this._applyX2ManyOperations(relDataPoint); + data[fieldName] = relDataPoint.res_ids; + } else { + // no datapoint has been created yet (because the loading of relational + // data has been batched, and hasn't started yet), so the value is still + // the list of ids in the relation + data[fieldName] = data[fieldName] || []; + } } else { data[fieldName] = this.get(data[fieldName]) || []; } diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js index dda3978635b..61a966972aa 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -204,7 +204,9 @@ var CalendarController = AbstractController.extend({ * @param {OdooEvent} event */ _onDropRecord: function (event) { - this._updateRecord(event.data); + this._updateRecord(_.extend({}, event.data, { + 'drop': true, + })); }, /** * @private diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index 1bcf4fbb298..0ad1b61e4d7 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -60,10 +60,11 @@ return AbstractModel.extend({ end.add(-1, 'days'); } + var isDateEvent = this.fields[this.mapping.date_start].type === 'date'; // An "allDay" event without the "all_day" option is not considered // as a 24h day. It's just a part of the day (by default: 7h-19h). if (event.allDay) { - if (!this.mapping.all_day) { + if (!this.mapping.all_day && !isDateEvent) { if (event.r_start) { start.hours(event.r_start.hours()) .minutes(event.r_start.minutes()) @@ -104,7 +105,9 @@ return AbstractModel.extend({ } if (this.mapping.date_delay) { - data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + if (this.data.scale !== 'month' || (this.data.scale === 'month' && !event.drop)) { + data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + } } return data; diff --git a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js index a1da2925eb6..5ce53b20806 100644 --- a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js +++ b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js @@ -18,7 +18,6 @@ var ControlPanelRenderer = Renderer.extend({ get_action_info: '_onGetActionInfo', }, events: _.extend({}, Renderer.prototype.events, { - 'click.bs.dropdown .o_search_options .dropdown-menu': '_onDropdownClicked', 'click .o_searchview_more': '_onMore', }), @@ -367,15 +366,6 @@ var ControlPanelRenderer = Renderer.extend({ // Handlers //-------------------------------------------------------------------------- - /** - * Prevent the search dropdowns from closing when clicking inside them. - * - * @private - * @param {MouseEvent} ev - */ - _onDropdownClicked: function (ev) { - ev.stopPropagation(); - }, /** * Toggle the search menus visibility. * diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 10d3c0d54c5..ffeef454716 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -466,7 +466,8 @@ var ListController = BasicController.extend({ _update: function () { return this._super.apply(this, arguments) .then(this._toggleSidebar.bind(this)) - .then(this._toggleCreateButton.bind(this)); + .then(this._toggleCreateButton.bind(this)) + .then(this._updateButtons.bind(this, 'readonly')); }, /** * This helper simply makes sure that the control panel buttons matches the diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index c68f5ab573a..aaf073eeea0 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -162,7 +162,7 @@ ListRenderer.include({ * This is a specialized version of confirmChange, meant to be called when * the change may have affected more than one line (so, for example, an * onchange which add/remove a few lines in a x2many. This does not occur - * in a normal list view) + * in a normal list view). * * The update is more difficult when other rows could have been changed. We * need to potentially remove some lines, add some other lines, update some @@ -172,6 +172,9 @@ ListRenderer.include({ * except the 'main' one (the row which caused the update), then rerender * every new row and add them before/after the main one. * + * Note that this function assumes that the list isn't grouped, which is + * fine as it's never the case for x2many lists. + * * @param {Object} state * @param {string} id * @param {string[]} fields @@ -226,18 +229,17 @@ ListRenderer.include({ } } - // remove all rows except the one being edited, and insert rows - // of the re-rendered body before and after it + // remove all data rows except the one being edited, and insert + // data rows of the re-rendered body before and after it var $editedRow = self._getRow(id); - $editedRow.nextAll().remove(); - $editedRow.prevAll().remove(); + $editedRow.nextAll('.o_data_row').remove(); + $editedRow.prevAll('.o_data_row').remove(); var $newRow = $newBody.find('.o_data_row[data-id="' + id + '"]'); - var $tbody = self.$('tbody'); - $newRow.prevAll().each(function (i, prevRow) { - $tbody.prepend($(prevRow)); + $newRow.prevAll('.o_data_row').get().reverse().forEach(function (row) { + $(row).insertBefore($editedRow); }); - $newRow.nextAll().each(function (i, nextRow) { - $tbody.append($(nextRow)); + $newRow.nextAll('.o_data_row').get().reverse().forEach(function (row) { + $(row).insertAfter($editedRow); }); if (self.currentRow !== null) { @@ -309,7 +311,16 @@ ListRenderer.include({ if (this.state.count >= 4) { $row.remove(); } else { - $row.replaceWith(this._renderEmptyRow()); + // we want to always keep at least 4 (possibly empty) rows + var $emptyRow = this._renderEmptyRow(); + $row.replaceWith($emptyRow); + if (this.editable === "top") { + // move the empty row we just inserted after data rows + var $lastDataRow = this.$('.o_data_row:last'); + if ($lastDataRow.length) { + $emptyRow.insertAfter($lastDataRow); + } + } } }, /** diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 4bf0ac82941..0d04fd69585 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -441,7 +441,6 @@ var ListRenderer = BasicRenderer.extend({ * @returns {jQuery} a @@ -1558,7 +1559,7 @@
  • -
  • -