diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 4346c3448ed..d32e2e3a6df 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -238,7 +238,7 @@ class AccountAccount(models.Model): company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company) tag_ids = fields.Many2many('account.account.tag', 'account_account_account_tag', string='Tags', help="Optional tags you may want to assign for custom reporting") - group_id = fields.Many2one('account.group') + group_id = fields.Many2one('account.group', compute='_compute_account_group', store=True) root_id = fields.Many2one('account.root', compute='_compute_account_root', store=True) allowed_journal_ids = fields.Many2many('account.journal', string="Allowed Journals", help="Define in which journals this account can be used. If empty, can be used in all journals.") @@ -281,6 +281,13 @@ class AccountAccount(models.Model): for record in self: record.root_id = record.code and (ord(record.code[0]) * 1000 + ord(record.code[1])) or False + @api.depends('code') + def _compute_account_group(self): + if self.ids: + self.env['account.group']._adapt_accounts_for_account_groups(self) + else: + self.group_id = False + def _search_used(self, operator, value): if operator not in ['=', '!='] or not isinstance(value, bool): raise UserError(_('Operation not supported')) @@ -404,22 +411,6 @@ class AccountAccount(models.Model): elif self.internal_group == 'expense' and not self.tax_ids: self.tax_ids = self.company_id.account_purchase_tax_id - @api.onchange('code') - def onchange_code(self): - AccountGroup = self.env['account.group'] - - group = False - code_prefix = self.code - - # find group with longest matching prefix - while code_prefix: - matching_group = AccountGroup.search([('code_prefix', '=', code_prefix)], limit=1) - if matching_group: - group = matching_group - break - code_prefix = code_prefix[:-1] - self.group_id = group - def name_get(self): result = [] for account in self: @@ -550,19 +541,40 @@ class AccountGroup(models.Model): _name = "account.group" _description = 'Account Group' _parent_store = True - _order = 'code_prefix' + _order = 'code_prefix_start' - parent_id = fields.Many2one('account.group', index=True, ondelete='cascade') + parent_id = fields.Many2one('account.group', index=True, ondelete='cascade', readonly=True) parent_path = fields.Char(index=True) name = fields.Char(required=True) - code_prefix = fields.Char() + code_prefix_start = fields.Char() + code_prefix_end = fields.Char() + company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company) + + _sql_constraints = [ + ( + 'check_length_prefix', + 'CHECK(char_length(COALESCE(code_prefix_start, \'\')) = char_length(COALESCE(code_prefix_end, \'\')))', + 'The length of the starting and the ending code prefix must be the same' + ), + ] + + @api.onchange('code_prefix_start') + def _onchange_code_prefix_start(self): + if not self.code_prefix_end or self.code_prefix_end < self.code_prefix_start: + self.code_prefix_end = self.code_prefix_start + + @api.onchange('code_prefix_end') + def _onchange_code_prefix_end(self): + if not self.code_prefix_start or self.code_prefix_start > self.code_prefix_end: + self.code_prefix_start = self.code_prefix_end def name_get(self): result = [] for group in self: - name = group.name - if group.code_prefix: - name = group.code_prefix + ' ' + name + prefix = group.code_prefix_start and str(group.code_prefix_start) + if prefix and group.code_prefix_end != group.code_prefix_start: + prefix += '-' + str(group.code_prefix_end) + name = (prefix and (prefix + ' ') or '') + group.name result.append((group.id, name)) return result @@ -573,10 +585,107 @@ class AccountGroup(models.Model): domain = [] else: criteria_operator = ['|'] if operator not in expression.NEGATIVE_TERM_OPERATORS else ['&', '!'] - domain = criteria_operator + [('code_prefix', '=ilike', name + '%'), ('name', operator, name)] + domain = criteria_operator + [('code_prefix_start', '=ilike', name + '%'), ('name', operator, name)] group_ids = self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid) return models.lazy_name_get(self.browse(group_ids).with_user(name_get_uid)) + @api.constrains('code_prefix_start', 'code_prefix_end') + def _constraint_prefix_overlap(self): + self.env['account.group'].flush() + query = """ + SELECT other.id FROM account_group this + JOIN account_group other + ON char_length(other.code_prefix_start) = char_length(this.code_prefix_start) + AND other.id != this.id + AND other.company_id = this.company_id + AND ( + other.code_prefix_start <= this.code_prefix_start AND this.code_prefix_start <= other.code_prefix_end + OR + other.code_prefix_start >= this.code_prefix_start AND this.code_prefix_end >= other.code_prefix_start + ) + WHERE this.id IN %(ids)s + """ + self.env.cr.execute(query, {'ids': tuple(self.ids)}) + res = self.env.cr.fetchall() + if res: + raise ValidationError(_('Account Groups with the same granularity can\'t overlap')) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'code_prefix_start' in vals and not vals.get('code_prefix_end'): + vals['code_prefix_end'] = vals['code_prefix_start'] + res_ids = super(AccountGroup, self).create(vals_list) + res_ids._adapt_accounts_for_account_groups() + res_ids._adapt_parent_account_group() + return res_ids + + def write(self, vals): + res = super(AccountGroup, self).write(vals) + if 'code_prefix_start' in vals or 'code_prefix_end' in vals: + self._adapt_accounts_for_account_groups() + self._adapt_parent_account_group() + return res + + def unlink(self): + for record in self: + account_ids = self.env['account.account'].search([('group_id', '=', record.id)]) + account_ids.write({'group_id': record.parent_id.id}) + + children_ids = self.env['account.group'].search([('parent_id', '=', record.id)]) + children_ids.write({'parent_id': record.parent_id.id}) + super(AccountGroup, self).unlink() + + def _adapt_accounts_for_account_groups(self, account_ids=None): + """Ensure consistency between accounts and account groups. + + Find and set the most specific group matching the code of the account. + The most specific is the one with the longest prefixes and with the starting + prefix being smaller than the account code and the ending prefix being greater. + """ + if not self and not account_ids: + return + self.env['account.group'].flush() + self.env['account.account'].flush() + query = """ + UPDATE account_account account SET group_id = ( + SELECT agroup.id FROM account_group agroup + WHERE agroup.code_prefix_start <= LEFT(account.code, char_length(agroup.code_prefix_start)) + AND agroup.code_prefix_end >= LEFT(account.code, char_length(agroup.code_prefix_end)) + AND agroup.company_id = account.company_id + ORDER BY char_length(agroup.code_prefix_start) DESC LIMIT 1 + ) WHERE account.company_id in %(company_ids)s {where_account}; + """.format( + where_account=account_ids and 'AND account.id IN %(account_ids)s' or '' + ) + self.env.cr.execute(query, {'company_ids': tuple((self.company_id or account_ids.company_id).ids), 'account_ids': account_ids and tuple(account_ids.ids)}) + self.env['account.account'].invalidate_cache(fnames=['group_id']) + + def _adapt_parent_account_group(self): + """Ensure consistency of the hierarchy of account groups. + + Find and set the most specific parent for each group. + The most specific is the one with the longest prefixes and with the starting + prefix being smaller than the child prefixes and the ending prefix being greater. + """ + if not self: + return + self.env['account.group'].flush() + query = """ + UPDATE account_group agroup SET parent_id = ( + SELECT parent.id FROM account_group parent + WHERE char_length(parent.code_prefix_start) < char_length(agroup.code_prefix_start) + AND parent.code_prefix_start <= LEFT(agroup.code_prefix_start, char_length(parent.code_prefix_start)) + AND parent.code_prefix_end >= LEFT(agroup.code_prefix_end, char_length(parent.code_prefix_end)) + AND parent.id != agroup.id + AND parent.company_id = %(company_id)s + ORDER BY char_length(parent.code_prefix_start) DESC LIMIT 1 + ) WHERE agroup.company_id = %(company_id)s; + """ + self.env.cr.execute(query, {'company_id': self.company_id.id}) + self.env['account.group'].invalidate_cache(fnames=['parent_id']) + self.env['account.group'].search([('company_id', '=', self.company_id.id)])._parent_store_update() + class AccountRoot(models.Model): _name = 'account.root' diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 5d11e7195a1..6a5220f50ed 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -35,6 +35,18 @@ def preserve_existing_tags_on_taxes(cr, registry, module): # --------------------------------------------------------------- +class AccountGroupTemplate(models.Model): + _name = "account.group.template" + _description = 'Template for Account Groups' + _order = 'code_prefix_start' + + parent_id = fields.Many2one('account.group.template', index=True, ondelete='cascade') + name = fields.Char(required=True) + code_prefix_start = fields.Char() + code_prefix_end = fields.Char() + chart_template_id = fields.Many2one('account.chart.template', string='Chart Template', required=True) + + class AccountAccountTemplate(models.Model): _name = "account.account.template" _description = 'Templates for Accounts' @@ -56,8 +68,6 @@ class AccountAccountTemplate(models.Model): help="This optional field allow you to link an account template to a specific chart template that may differ from the one its root parent belongs to. This allow you " "to define chart templates that extend another and complete it with few new accounts (You don't need to define the whole structure that is common to both several times).") tag_ids = fields.Many2many('account.account.tag', 'account_account_template_account_tag', string='Account tag', help="Optional tags you may want to assign for custom reporting") - group_id = fields.Many2one('account.group') - root_id = fields.Many2one('account.root') @api.depends('name', 'code') def name_get(self): @@ -193,7 +203,7 @@ class AccountChartTemplate(models.Model): accounting_props.sudo().unlink() # delete account, journal, tax, fiscal position and reconciliation model - models_to_delete = ['account.reconcile.model', 'account.fiscal.position', 'account.tax', 'account.move', 'account.journal'] + models_to_delete = ['account.reconcile.model', 'account.fiscal.position', 'account.tax', 'account.move', 'account.journal', 'account.group'] for model in models_to_delete: res = self.env[model].sudo().search([('company_id', '=', company.id)]) if len(res): @@ -544,6 +554,9 @@ class AccountChartTemplate(models.Model): account_template_ref = self.generate_account(taxes_ref, account_ref, code_digits, company) account_ref.update(account_template_ref) + # Generate account groups, from template + self.generate_account_groups(company) + # writing account values after creation of accounts 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']: @@ -635,7 +648,6 @@ class AccountChartTemplate(models.Model): 'tax_ids': [(6, 0, tax_ids)], 'company_id': company.id, 'tag_ids': [(6, 0, [t.id for t in account_template.tag_ids])], - 'group_id': account_template.group_id.id, } return val @@ -665,6 +677,23 @@ class AccountChartTemplate(models.Model): acc_template_ref[template.id] = account.id return acc_template_ref + def generate_account_groups(self, company): + """ This method generates account groups from account groups templates. + :param company: company to generate the account groups for + """ + self.ensure_one() + group_templates = self.env['account.group.template'].search([('chart_template_id', '=', self.id)]) + template_vals = [] + for group_template in group_templates: + vals = { + 'name': group_template.name, + 'code_prefix_start': group_template.code_prefix_start, + 'code_prefix_end': group_template.code_prefix_end, + 'company_id': company.id, + } + template_vals.append((group_template, vals)) + groups = self._create_records_with_xmlid('account.group', template_vals, company) + def _prepare_reconcile_model_vals(self, company, account_reconcile_model, acc_template_ref, tax_template_ref): """ This method generates a dictionary of all the values for the account.reconcile.model that will be created. """ diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index e36c40d8b22..c665789497f 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -124,6 +124,13 @@ ['|',('company_id','=',False),('company_id', 'in', company_ids)] + + Account Group multi-company + + + [('company_id', 'in', company_ids)] + + Account Root multi-company diff --git a/addons/account/security/ir.model.access.csv b/addons/account/security/ir.model.access.csv index 78dda1abdb3..3ee0856bb24 100644 --- a/addons/account/security/ir.model.access.csv +++ b/addons/account/security/ir.model.access.csv @@ -13,6 +13,7 @@ access_account_account_user,account.account user,model_account_account,base.grou access_account_account_partner_manager,account.account partner manager,model_account_account,base.group_partner_manager,1,0,0,0 access_account_group_manager,account.group,model_account_group,account.group_account_manager,1,1,1,1 access_account_group,account.group,model_account_group,account.group_account_user,1,0,0,0 +access_account_group_template,account.group.template,model_account_group_template,account.group_account_manager,1,1,1,1 access_account_root_manager,account.root,model_account_root,account.group_account_manager,1,0,0,0 access_account_root,account.root,model_account_root,account.group_account_user,1,0,0,0 access_account_tax,account.tax,model_account_tax,account.group_account_invoice,1,0,0,0 diff --git a/addons/account/tests/test_templates_consistency.py b/addons/account/tests/test_templates_consistency.py index aaa2217a515..f91599cd552 100644 --- a/addons/account/tests/test_templates_consistency.py +++ b/addons/account/tests/test_templates_consistency.py @@ -34,7 +34,7 @@ class AccountingTestTemplConsistency(TransactionCase): self.check_fields_consistency( 'account.account.template', 'account.account', exceptions=['chart_template_id', 'nocreate']) self.check_fields_consistency( - 'account.account', 'account.account.template', exceptions=['company_id', 'deprecated', 'opening_debit', 'opening_credit', 'allowed_journal_ids']) + 'account.account', 'account.account.template', exceptions=['company_id', 'deprecated', 'opening_debit', 'opening_credit', 'allowed_journal_ids', 'group_id', 'root_id']) def test_account_tax_fields(self): '''Test fields consistency for ('account.tax', 'account.tax.template') @@ -62,3 +62,9 @@ class AccountingTestTemplConsistency(TransactionCase): ''' self.check_fields_consistency('account.reconcile.model.template', 'account.reconcile.model', exceptions=['chart_template_id']) self.check_fields_consistency('account.reconcile.model', 'account.reconcile.model.template', exceptions=['company_id', 'journal_id', 'analytic_account_id', 'second_journal_id', 'second_analytic_account_id', 'analytic_tag_ids', 'second_analytic_tag_ids']) + + def test_account_group_fields(self): + '''Test fields consistency for ('account.group', 'account.group.template') + ''' + self.check_fields_consistency('account.group', 'account.group.template', exceptions=['company_id', 'parent_path']) + self.check_fields_consistency('account.group.template', 'account.group', exceptions=['chart_template_id']) diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index b3e11cf47c7..0a819cb2f78 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -18,7 +18,6 @@ - @@ -1790,8 +1789,11 @@ action = model.setting_init_bank_account_action() - - + @@ -1804,7 +1806,7 @@ action = model.setting_init_bank_account_action() @@ -1815,8 +1817,10 @@ action = model.setting_init_bank_account_action() account.group - + + +