19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -7,3 +7,4 @@ from . import analytic_line
from . import analytic_mixin
from . import analytic_distribution_model
from . import res_config_settings
from . import ir_config_parameter

View file

@ -2,8 +2,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
import itertools
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.exceptions import UserError, RedirectWarning
from odoo.tools import groupby, SQL
class AccountAnalyticAccount(models.Model):
@ -12,6 +14,7 @@ class AccountAnalyticAccount(models.Model):
_description = 'Analytic Account'
_order = 'plan_id, name asc'
_check_company_auto = True
_check_company_domain = models.check_company_domain_parent_of
_rec_names_search = ['name', 'code']
name = fields.Char(
@ -19,6 +22,7 @@ class AccountAnalyticAccount(models.Model):
index='trigram',
required=True,
tracking=True,
translate=True,
)
code = fields.Char(
string='Reference',
@ -31,18 +35,16 @@ class AccountAnalyticAccount(models.Model):
default=True,
tracking=True,
)
plan_id = fields.Many2one(
'account.analytic.plan',
string='Plan',
check_company=True,
required=True,
index=True,
)
root_plan_id = fields.Many2one(
'account.analytic.plan',
string='Root Plan',
check_company=True,
compute="_compute_root_plan",
related="plan_id.root_id",
store=True,
)
color = fields.Integer(
@ -52,7 +54,7 @@ class AccountAnalyticAccount(models.Model):
line_ids = fields.One2many(
'account.analytic.line',
'account_id',
'auto_account_id', # magic link to the right column (plan) by using the context in the view
string="Analytic Lines",
)
@ -62,13 +64,14 @@ class AccountAnalyticAccount(models.Model):
default=lambda self: self.env.company,
)
# use auto_join to speed up name_search call
partner_id = fields.Many2one(
'res.partner',
string='Customer',
auto_join=True,
# use bypass_access to speed up name_search call
bypass_search_access=True,
tracking=True,
check_company=True,
index='btree_not_null',
)
balance = fields.Monetary(
@ -91,101 +94,150 @@ class AccountAnalyticAccount(models.Model):
@api.constrains('company_id')
def _check_company_consistency(self):
analytic_accounts = self.filtered('company_id')
for company, accounts in groupby(self, lambda account: account.company_id):
if company and self.env['account.analytic.line'].sudo().search_count([
('auto_account_id', 'in', [account.id for account in accounts]),
'!', ('company_id', 'child_of', company.id),
], limit=1):
raise UserError(_("You can't change the company of an analytic account that already has analytic items! It's a recipe for an analytical disaster!"))
if not analytic_accounts:
return
self.flush_recordset(['company_id'])
self.env['account.analytic.line'].flush_model(['account_id', 'company_id'])
self._cr.execute('''
SELECT line.account_id
FROM account_analytic_line line
JOIN account_analytic_account account ON line.account_id = account.id
WHERE line.company_id != account.company_id and account.company_id IS NOT NULL
AND account.id IN %s
''', [tuple(self.ids)])
if self._cr.fetchone():
raise UserError(_("You can't set a different company on your analytic account since there are some analytic items linked to it."))
def name_get(self):
res = []
@api.depends('code', 'partner_id')
def _compute_display_name(self):
for analytic in self:
name = analytic.name
if analytic.code:
name = f'[{analytic.code}] {name}'
if analytic.partner_id.commercial_partner_id.name:
name = f'{name} - {analytic.partner_id.commercial_partner_id.name}'
res.append((analytic.id, name))
return res
analytic.display_name = name
def copy_data(self, default=None):
default = dict(default or {})
default.setdefault('name', _("%s (copy)", self.name))
return super().copy_data(default)
vals_list = super().copy_data(default=default)
if 'name' not in default:
for account, vals in zip(self, vals_list):
vals['name'] = _("%s (copy)", account.name)
return vals_list
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
"""
Override read_group to calculate the sum of the non-stored fields that depend on the user context
"""
res = super(AccountAnalyticAccount, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
accounts = self.env['account.analytic.account']
for line in res:
if '__domain' in line:
accounts = self.search(line['__domain'])
if 'balance' in fields:
line['balance'] = sum(accounts.mapped('balance'))
if 'debit' in fields:
line['debit'] = sum(accounts.mapped('debit'))
if 'credit' in fields:
line['credit'] = sum(accounts.mapped('credit'))
return res
def web_read(self, specification: dict[str, dict]) -> list[dict]:
self_context = self
if len(self) == 1:
self_context = self.with_context(analytic_plan_id=self.plan_id.id)
return super(AccountAnalyticAccount, self_context).web_read(specification)
def _read_group_select(self, aggregate_spec, query):
# flag balance/debit/credit as aggregatable, and manually sum the values
# from the records in the group
if aggregate_spec in (
'balance:sum',
'balance:sum_currency',
'debit:sum',
'debit:sum_currency',
'credit:sum',
'credit:sum_currency',
):
return super()._read_group_select('id:recordset', query)
return super()._read_group_select(aggregate_spec, query)
def _read_group_postprocess_aggregate(self, aggregate_spec, raw_values):
if aggregate_spec in (
'balance:sum',
'balance:sum_currency',
'debit:sum',
'debit:sum_currency',
'credit:sum',
'credit:sum_currency',
):
field_name, op = aggregate_spec.split(':')
column = super()._read_group_postprocess_aggregate('id:recordset', raw_values)
if op == 'sum':
return (sum(records.mapped(field_name)) for records in column)
if op == 'sum_currency':
return (sum(record.currency_id._convert(
from_amount=record[field_name],
to_currency=self.env.company.currency_id,
) for record in records) for records in column)
return super()._read_group_postprocess_aggregate(aggregate_spec, raw_values)
@api.depends('line_ids.amount')
def _compute_debit_credit_balance(self):
Curr = self.env['res.currency']
analytic_line_obj = self.env['account.analytic.line']
domain = [
('account_id', 'in', self.ids),
('company_id', 'in', [False] + self.env.companies.ids)
]
if self._context.get('from_date', False):
domain.append(('date', '>=', self._context['from_date']))
if self._context.get('to_date', False):
domain.append(('date', '<=', self._context['to_date']))
def convert(amount, from_currency):
return from_currency._convert(
from_amount=amount,
to_currency=self.env.company.currency_id,
company=self.env.company,
date=fields.Date.today(),
)
user_currency = self.env.company.currency_id
credit_groups = analytic_line_obj.read_group(
domain=domain + [('amount', '>=', 0.0)],
fields=['account_id', 'currency_id', 'amount'],
groupby=['account_id', 'currency_id'],
lazy=False,
)
data_credit = defaultdict(float)
for l in credit_groups:
data_credit[l['account_id'][0]] += Curr.browse(l['currency_id'][0])._convert(
l['amount'], user_currency, self.env.company, fields.Date.today())
domain = [('company_id', 'in', [False] + self.env.companies.ids)]
if self.env.context.get('from_date', False):
domain.append(('date', '>=', self.env.context['from_date']))
if self.env.context.get('to_date', False):
domain.append(('date', '<=', self.env.context['to_date']))
debit_groups = analytic_line_obj.read_group(
domain=domain + [('amount', '<', 0.0)],
fields=['account_id', 'currency_id', 'amount'],
groupby=['account_id', 'currency_id'],
lazy=False,
)
data_debit = defaultdict(float)
for l in debit_groups:
data_debit[l['account_id'][0]] += Curr.browse(l['currency_id'][0])._convert(
l['amount'], user_currency, self.env.company, fields.Date.today())
for plan, accounts in self.grouped('plan_id').items():
if not plan:
accounts.debit = accounts.credit = accounts.balance = 0
continue
credit_groups = self.env['account.analytic.line']._read_group(
domain=domain + [(plan._column_name(), 'in', self.ids), ('amount', '>=', 0.0)],
groupby=[plan._column_name(), 'currency_id'],
aggregates=['amount:sum'],
)
data_credit = defaultdict(float)
for account, currency, amount_sum in credit_groups:
data_credit[account.id] += convert(amount_sum, currency)
for account in self:
account.debit = abs(data_debit.get(account.id, 0.0))
account.credit = data_credit.get(account.id, 0.0)
account.balance = account.credit - account.debit
debit_groups = self.env['account.analytic.line']._read_group(
domain=domain + [(plan._column_name(), 'in', self.ids), ('amount', '<', 0.0)],
groupby=[plan._column_name(), 'currency_id'],
aggregates=['amount:sum'],
)
data_debit = defaultdict(float)
for account, currency, amount_sum in debit_groups:
data_debit[account.id] += convert(amount_sum, currency)
@api.depends('plan_id', 'plan_id.parent_path')
def _compute_root_plan(self):
for account in self:
account.root_plan_id = int(account.plan_id.parent_path[:-1].split('/')[0]) if account.plan_id.parent_path else None
for account in accounts:
account.debit = -data_debit.get(account.id, 0.0)
account.credit = data_credit.get(account.id, 0.0)
account.balance = account.credit - account.debit
def _update_accounts_in_analytic_lines(self, new_fname, current_fname, accounts):
if current_fname != new_fname:
domain = [
(new_fname, 'not in', accounts.ids + [False]),
(current_fname, 'in', accounts.ids),
]
if self.env['account.analytic.line'].sudo().search_count(domain, limit=1):
list_view = self.env.ref('analytic.view_account_analytic_line_tree', raise_if_not_found=False)
raise RedirectWarning(
message=_("Whoa there! Making this change would wipe out your current data. Let's avoid that, shall we?"),
action={
'res_model': 'account.analytic.line',
'type': 'ir.actions.act_window',
'domain': domain,
'target': 'new',
'views': [(list_view and list_view.id, 'list')]
},
button_text=_("See them"),
)
self.env.cr.execute(SQL(
"""
UPDATE account_analytic_line
SET %(new_fname)s = %(current_fname)s,
%(current_fname)s = NULL
WHERE %(current_fname)s = ANY(%(account_ids)s)
""",
new_fname=SQL.identifier(new_fname),
current_fname=SQL.identifier(current_fname),
account_ids=accounts.ids,
))
self.env['account.analytic.line'].invalidate_model()
def write(self, vals):
if vals.get('plan_id'):
new_fname = self.env['account.analytic.plan'].browse(vals['plan_id'])._column_name()
for plan, accounts in self.grouped('plan_id').items():
current_fname = plan._column_name()
self._update_accounts_in_analytic_lines(new_fname, current_fname, accounts)
return super().write(vals)

View file

@ -2,20 +2,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.tools import SQL
from odoo.exceptions import UserError
class NonMatchingDistribution(Exception):
pass
class AccountAnalyticDistributionModel(models.Model):
_name = 'account.analytic.distribution.model'
_inherit = 'analytic.mixin'
_inherit = ['analytic.mixin']
_description = 'Analytic Distribution Model'
_rec_name = 'create_date'
_order = 'id desc'
_order = 'sequence, id desc'
_check_company_auto = True
_check_company_domain = models.check_company_domain_parent_of
sequence = fields.Integer(default=10)
partner_id = fields.Many2one(
'res.partner',
string='Partner',
@ -38,15 +38,20 @@ class AccountAnalyticDistributionModel(models.Model):
@api.constrains('company_id')
def _check_company_accounts(self):
query = """
"""Ensure accounts specific to a company isn't used in any distribution model that wouldn't be specific to the company"""
query = SQL(
"""
SELECT model.id
FROM account_analytic_distribution_model model
JOIN account_analytic_account account
ON model.analytic_distribution ? CAST(account.id AS VARCHAR)
WHERE account.company_id IS NOT NULL
ON ARRAY[account.id::text] && %s
WHERE account.company_id IS NOT NULL AND model.id = ANY(%s)
AND (model.company_id IS NULL
OR model.company_id != account.company_id)
"""
""",
self._query_analytic_accounts('model'),
self.ids,
)
self.flush_model(['company_id', 'analytic_distribution'])
self.env.cr.execute(query)
if self.env.cr.dictfetchone():
@ -54,62 +59,38 @@ class AccountAnalyticDistributionModel(models.Model):
@api.model
def _get_distribution(self, vals):
""" Returns the distribution model that has the most fields that corresponds to the vals given
""" Returns the combined distribution from all matching models based on the vals dict provided
This method should be called to prefill analytic distribution field on several models """
domain = []
for fname, value in vals.items():
domain += self._create_domain(fname, value) or []
best_score = 0
applicable_models = self._get_applicable_models({k: v for k, v in vals.items() if k != 'related_root_plan_ids'})
res = {}
fnames = set(self._get_fields_to_check())
for rec in self.search(domain):
try:
score = sum(rec._check_score(key, vals.get(key)) for key in fnames)
if score > best_score:
res = rec.analytic_distribution
best_score = score
except NonMatchingDistribution:
continue
applied_plans = vals.get('related_root_plan_ids', self.env['account.analytic.plan'])
for model in applicable_models:
# ignore model if it contains an account having a root plan that was already applied
if not applied_plans & model.distribution_analytic_account_ids.root_plan_id:
res |= model.analytic_distribution or {}
applied_plans += model.distribution_analytic_account_ids.root_plan_id
return res
def _get_fields_to_check(self):
return (
{field.name for field in self._fields.values() if not field.manual}
- set(self.env['analytic.mixin']._fields)
- set(models.MAGIC_COLUMNS) - {'display_name', '__last_update'}
)
@api.model
def _get_default_search_domain_vals(self):
return {
'company_id': False,
'partner_id': False,
'partner_category_id': [],
}
def _check_score(self, key, value):
self.ensure_one()
if key == 'company_id':
if not self.company_id or value == self.company_id.id:
return 1 if self.company_id else 0.5
raise NonMatchingDistribution
if not self[key]:
return 0
if value and ((self[key].id in value) if isinstance(value, (list, tuple))
else (value.startswith(self[key])) if key.endswith('_prefix')
else (value == self[key].id)
):
return 1
raise NonMatchingDistribution
@api.model
def _get_applicable_models(self, vals):
vals = self._get_default_search_domain_vals() | vals
domain = []
for fname, value in vals.items():
domain += self._create_domain(fname, value)
return self.search(domain)
def _create_domain(self, fname, value):
if not value:
return False
if fname == 'partner_category_id':
value += [False]
return [(fname, 'in', value)]
else:
return [(fname, 'in', [value, False])]
def action_read_distribution_model(self):
self.ensure_one()
return {
'name': self.display_name,
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'account.analytic.distribution.model',
'res_id': self.id,
}

View file

@ -1,12 +1,159 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from lxml.builder import E
from odoo import api, fields, models, _
from odoo.tools import date_utils
from odoo.exceptions import ValidationError
from odoo.fields import Domain
class AnalyticPlanFieldsMixin(models.AbstractModel):
""" Add one field per analytic plan to the model """
_name = 'analytic.plan.fields.mixin'
_description = 'Analytic Plan Fields'
account_id = fields.Many2one(
'account.analytic.account',
'Project Account',
ondelete='restrict',
index=True,
check_company=True,
)
# Magic column that represents all the plans at the same time, except for the compute
# where it is context dependent, and needs the id of the desired plan.
# Used as a syntactic sugar for search views, and magic field for one2many relation
auto_account_id = fields.Many2one(
comodel_name='account.analytic.account',
string='Analytic Account',
compute='_compute_auto_account',
inverse='_inverse_auto_account',
search='_search_auto_account',
)
@api.depends_context('analytic_plan_id')
def _compute_auto_account(self):
plan = self.env['account.analytic.plan'].browse(self.env.context.get('analytic_plan_id'))
for line in self:
line.auto_account_id = bool(plan) and line[plan._column_name()]
def _compute_partner_id(self):
# TO OVERRIDE
pass
def _inverse_auto_account(self):
for line in self:
line[line.auto_account_id.plan_id._column_name()] = line.auto_account_id
def _search_auto_account(self, operator, value):
if operator in Domain.NEGATIVE_OPERATORS:
return NotImplemented
project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
return Domain.OR([
[(plan._column_name(), operator, value)]
for plan in project_plan + other_plans
])
def _get_plan_fnames(self):
project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
return [fname for plan in project_plan + other_plans if (fname := plan._column_name()) in self]
def _get_analytic_accounts(self):
return self.env['account.analytic.account'].browse([
self[fname].id
for fname in self._get_plan_fnames()
if self[fname]
])
def _get_distribution_key(self):
return ",".join(str(account_id) for account_id in self._get_analytic_accounts().ids)
def _get_analytic_distribution(self):
accounts = self._get_distribution_key()
return {} if not accounts else {accounts: 100}
def _get_mandatory_plans(self, company, business_domain):
return [
{
'name': plan['name'],
'column_name': plan['column_name'],
}
for plan in self.env['account.analytic.plan']
.sudo().with_company(company)
.get_relevant_plans(business_domain=business_domain, company_id=company.id)
if plan['applicability'] == 'mandatory'
]
def _get_plan_domain(self, plan):
return [('plan_id', 'child_of', plan.id)]
def _get_account_node_context(self, plan):
return {'default_plan_id': plan.id}
@api.constrains(lambda self: self._get_plan_fnames())
def _check_account_id(self):
fnames = self._get_plan_fnames()
for line in self:
if not any(line[fname] for fname in fnames):
raise ValidationError(_("At least one analytic account must be set"))
@api.model
def fields_get(self, allfields=None, attributes=None):
fields = super().fields_get(allfields, attributes)
if not self.env.context.get("studio") and self.env['account.analytic.plan'].has_access('read'):
project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
for plan in project_plan + other_plans:
fname = plan._column_name()
if fname in fields:
fields[fname]['string'] = plan.name
fields[fname]['domain'] = repr(self._get_plan_domain(plan))
return fields
def _get_view(self, view_id=None, view_type='form', **options):
arch, view = super()._get_view(view_id, view_type, **options)
return self._patch_view(arch, view, view_type)
def _patch_view(self, arch, view, view_type):
if not self.env.context.get("studio") and self.env['account.analytic.plan'].has_access('read'):
project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
# Find main account nodes
account_node = arch.find('.//field[@name="account_id"]')
account_filter_node = arch.find('.//filter[@name="account_id"]')
# Force domain on main account node as the fields_get doesn't do the trick
if account_node is not None and view_type == 'search':
account_node.set('domain', repr(self._get_plan_domain(project_plan)))
# If there is a main node, append the ones for other plans
if account_node is not None:
account_node.set('context', repr(self._get_account_node_context(project_plan)))
for plan in other_plans[::-1]:
fname = plan._column_name()
if account_node is not None:
account_node.addnext(E.field(**{
'optional': 'show',
**account_node.attrib,
'name': fname,
'domain': repr(self._get_plan_domain(plan)),
'context': repr(self._get_account_node_context(plan)),
}))
if account_filter_node is not None:
for plan in other_plans[::-1] + project_plan:
fname = plan._column_name()
if plan != project_plan:
account_filter_node.addnext(E.filter(name=fname, context=f"{{'group_by': '{fname}'}}"))
current = plan
while current := current.children_ids:
_depth, subfname = current[0]._hierarchy_name()
if subfname in self._fields:
account_filter_node.addnext(E.filter(name=subfname, context=f"{{'group_by': '{subfname}'}}"))
return arch, view
class AccountAnalyticLine(models.Model):
_name = 'account.analytic.line'
_inherit = ['analytic.plan.fields.mixin']
_description = 'Analytic Line'
_order = 'date desc, id desc'
_check_company_auto = True
@ -32,21 +179,7 @@ class AccountAnalyticLine(models.Model):
)
product_uom_id = fields.Many2one(
'uom.uom',
string='Unit of Measure',
domain="[('category_id', '=', product_uom_category_id)]",
)
product_uom_category_id = fields.Many2one(
related='product_uom_id.category_id',
string='UoM Category',
readonly=True,
)
account_id = fields.Many2one(
'account.analytic.account',
'Analytic Account',
required=True,
ondelete='restrict',
index=True,
check_company=True,
string='Unit',
)
partner_id = fields.Many2one(
'res.partner',
@ -73,20 +206,60 @@ class AccountAnalyticLine(models.Model):
store=True,
compute_sudo=True,
)
plan_id = fields.Many2one(
'account.analytic.plan',
related='account_id.plan_id',
store=True,
readonly=True,
compute_sudo=True,
)
category = fields.Selection(
[('other', 'Other')],
default='other',
)
fiscal_year_search = fields.Boolean(
search='_search_fiscal_date',
store=False, exportable=False,
export_string_translation=False,
)
analytic_distribution = fields.Json(
'Analytic Distribution',
compute="_compute_analytic_distribution",
inverse='_inverse_analytic_distribution',
)
analytic_precision = fields.Integer(
store=False,
default=lambda self: self.env['decimal.precision'].precision_get("Percentage Analytic"),
)
@api.constrains('company_id', 'account_id')
def _check_company_id(self):
def _compute_analytic_distribution(self):
for line in self:
if line.account_id.company_id and line.company_id.id != line.account_id.company_id.id:
raise ValidationError(_('The selected account belongs to another company than the one you\'re trying to create an analytic item for'))
line.analytic_distribution = {line._get_distribution_key(): 100}
def _inverse_analytic_distribution(self):
empty_account = dict.fromkeys(self._get_plan_fnames(), False)
to_create_vals = []
for line in self:
final_distribution = self.env['analytic.mixin']._merge_distribution(
{line._get_distribution_key(): 100},
line.analytic_distribution or {},
)
if not final_distribution:
continue
amount_fname = line._split_amount_fname()
vals_list = [
{amount_fname: line[amount_fname] * percent / 100} | empty_account | {
account.plan_id._column_name(): account.id
for account in self.env['account.analytic.account'].browse(int(aid) for aid in account_ids.split(','))
}
for account_ids, percent in final_distribution.items()
]
line.write(vals_list[0])
to_create_vals += [line.copy_data(vals)[0] for vals in vals_list[1:]]
if to_create_vals:
self.create(to_create_vals)
self.env.user._bus_send('simple_notification', {
'type': 'success',
'message': self.env._("%s analytic lines created", len(to_create_vals)),
})
def _split_amount_fname(self):
return 'amount'
def _search_fiscal_date(self, operator, value):
fiscalyear_date_range = self.env.company.compute_fiscalyear_dates(fields.Date.today())
return [('date', '>=', fiscalyear_date_range['date_from'] - relativedelta(years=1))]

View file

@ -1,79 +1,170 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api, _
from odoo.tools.float_utils import float_round, float_compare
from collections import defaultdict
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.fields import Domain
from odoo.tools import SQL, Query, unique
from odoo.tools.float_utils import float_compare, float_round
from odoo.tools.sql import table_exists
class AnalyticMixin(models.AbstractModel):
_name = 'analytic.mixin'
_description = 'Analytic Mixin'
analytic_distribution = fields.Json(
'Analytic',
compute="_compute_analytic_distribution", store=True, copy=True, readonly=False,
)
# Json non stored to be able to search on analytic_distribution.
analytic_distribution_search = fields.Json(
store=False,
search="_search_analytic_distribution"
'Analytic Distribution',
compute="_compute_analytic_distribution",
search="_search_analytic_distribution",
store=True, copy=True, readonly=False,
)
analytic_precision = fields.Integer(
store=False,
default=lambda self: self.env['decimal.precision'].precision_get("Percentage Analytic"),
)
distribution_analytic_account_ids = fields.Many2many(
comodel_name='account.analytic.account',
compute='_compute_distribution_analytic_account_ids',
search='_search_distribution_analytic_account_ids',
)
def init(self):
# Add a gin index for json search on the keys, on the models that actually have a table
query = ''' SELECT table_name
FROM information_schema.tables
WHERE table_name=%s '''
self.env.cr.execute(query, [self._table])
if self.env.cr.dictfetchone():
query = f"""
CREATE INDEX IF NOT EXISTS {self._table}_analytic_distribution_gin_index
ON {self._table} USING gin(analytic_distribution);
if table_exists(self.env.cr, self._table) and self._fields['analytic_distribution'].store:
query = fr"""
CREATE INDEX IF NOT EXISTS {self._table}_analytic_distribution_accounts_gin_index
ON {self._table} USING gin(regexp_split_to_array(jsonb_path_query_array(analytic_distribution, '$.keyvalue()."key"')::text, '\D+'));
"""
self.env.cr.execute(query)
super().init()
def _query_analytic_accounts(self, table=False):
return SQL(
r"""regexp_split_to_array(jsonb_path_query_array(%s, '$.keyvalue()."key"')::text, '\D+')""",
self._field_to_sql(table or self._table, 'analytic_distribution'),
)
@api.model
def fields_get(self, allfields=None, attributes=None):
""" Hide analytic_distribution_search from filterable/searchable fields"""
res = super().fields_get(allfields, attributes)
if res.get('analytic_distribution_search'):
res['analytic_distribution_search']['searchable'] = False
return res
def _get_analytic_account_ids_from_distributions(self, distributions):
if not distributions:
return []
if isinstance(distributions, (list, tuple, set)):
return {int(_id) for distribution in distributions for key in (distribution or {}) for _id in key.split(',')}
else:
return {int(_id) for key in (distributions or {}) for _id in key.split(',')}
@api.depends('analytic_distribution')
def _compute_distribution_analytic_account_ids(self):
all_ids = {int(_id) for rec in self for key in (rec.analytic_distribution or {}) for _id in key.split(',') if _id.isdigit()}
existing_accounts_ids = set(self.env['account.analytic.account'].browse(all_ids).exists().ids)
for rec in self:
ids = list(unique(int(_id) for key in (rec.analytic_distribution or {}) for _id in key.split(',') if _id.isdigit() and int(_id) in existing_accounts_ids))
rec.distribution_analytic_account_ids = self.env['account.analytic.account'].browse(ids)
def _search_distribution_analytic_account_ids(self, operator, value):
if operator in ('any', 'not any', 'any!', 'not any!'):
if isinstance(value, Domain):
value = self.env['account.analytic.account'].search(value).ids
elif isinstance(value, Query):
value = value.get_result_ids()
else:
return NotImplemented
operator = 'in' if operator in ('any', 'any!') else 'not in'
return [('analytic_distribution', operator, value)]
def _compute_analytic_distribution(self):
pass
def _search_analytic_distribution(self, operator, value):
if operator == 'in' and isinstance(value, (tuple, list)):
account_ids = value
operator_inselect = 'inselect'
elif operator in ('=', '!=', 'ilike', 'not ilike') and isinstance(value, (str, bool)):
operator_name_search = '=' if operator in ('=', '!=') else 'ilike'
account_ids = list(self.env['account.analytic.account']._name_search(name=value, operator=operator_name_search))
operator_inselect = 'inselect' if operator in ('=', 'ilike') else 'not inselect'
# Don't use this override when account_report_analytic_groupby is truly in the context
# Indeed, when account_report_analytic_groupby is in the context it means that `analytic_distribution`
# doesn't have the same format and the table is a temporary one, see _prepare_lines_for_analytic_groupby
if self.env.context.get('account_report_analytic_groupby') or (operator in ('in', 'not in') and False in value):
return Domain('analytic_distribution', operator, value)
def search_value(value: str, exact: bool):
return list(self.env['account.analytic.account']._search(
[('display_name', ('=' if exact else 'ilike'), value)]
))
# reformulate the condition as <field> in/not in <ids>
if operator in ('in', 'not in'):
ids = [
r
for v in value
for r in (search_value(v, exact=True) if isinstance(value, str) else [v])
]
elif operator in ('ilike', 'not ilike'):
ids = search_value(value, exact=False)
operator = 'not in' if operator.startswith('not') else 'in'
else:
raise UserError(_('Operation not supported'))
query = f"""
SELECT id
FROM {self._table}
WHERE analytic_distribution ?| array[%s]
"""
return [('id', operator_inselect, (query, [[str(account_id) for account_id in account_ids]]))]
if not ids:
# not ids found, just let it optimize to a constant
return Domain(operator == 'not in')
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
args = self._apply_analytic_distribution_domain(args)
return super()._search(args, offset, limit, order, count, access_rights_uid)
# keys can be comma-separated ids, we will split those into an array and then make an array comparison with the list of ids to check
ids = [str(id_) for id_ in ids if id_] # list of ids -> list of string
if operator == 'in':
return Domain.custom(to_sql=lambda model, alias, query: SQL(
"%s && %s",
self._query_analytic_accounts(alias),
ids,
))
else:
return Domain.custom(to_sql=lambda model, alias, query: SQL(
"(NOT %s && %s OR %s IS NULL)",
self._query_analytic_accounts(alias),
ids,
model._field_to_sql(alias, 'analytic_distribution', query),
))
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
domain = self._apply_analytic_distribution_domain(domain)
return super().read_group(domain, fields, groupby, offset, limit, orderby, lazy)
def _read_group_groupby(self, alias: str, groupby_spec: str, query: Query) -> SQL:
"""To group by `analytic_distribution`, we first need to separate the analytic_ids and associate them with the ids to be counted
Do note that only '__count' can be passed in the `aggregates`"""
if groupby_spec == 'analytic_distribution':
query._tables = {
'distribution': SQL(
r"""(SELECT DISTINCT %s, (regexp_matches(jsonb_object_keys(%s), '\d+', 'g'))[1]::int AS account_id FROM %s WHERE %s)""",
self._get_count_id(query),
self._field_to_sql(self._table, 'analytic_distribution', query),
query.from_clause,
query.where_clause,
)
}
# After using the from and where clauses in the nested query, they are no longer needed in the main one
query._joins = {}
query._where_clauses = []
return SQL("account_id")
return super()._read_group_groupby(alias, groupby_spec, query)
def _read_group_select(self, aggregate_spec: str, query: Query) -> SQL:
if query.table == 'distribution' and aggregate_spec != '__count':
raise ValueError(f"analytic_distribution grouping does not accept {aggregate_spec} as aggregate.")
return super()._read_group_select(aggregate_spec, query)
def _get_count_id(self, query):
ids = {
'account_move_line': "move_id",
'purchase_order_line': "order_id",
'account_asset': "id",
'hr_expense': "id",
}
if query.table not in ids:
raise ValueError(f"{query.table} does not support analytic_distribution grouping.")
return SQL(ids.get(query.table))
def filtered_domain(self, domain):
# Filter based on the accounts used (i.e. allowing a name_search) instead of the distribution
# A domain on a binary field doesn't make sense anymore outside of set or not; and it is still doable.
# Hack to filter using another field.
domain = Domain(domain).map_conditions(lambda cond: Domain('distribution_analytic_account_ids', cond.operator, cond.value) if cond.field_expr == 'analytic_distribution' else cond)
return super().filtered_domain(domain)
def write(self, vals):
""" Format the analytic_distribution float value, so equality on analytic_distribution can be done """
@ -90,14 +181,15 @@ class AnalyticMixin(models.AbstractModel):
def _validate_distribution(self, **kwargs):
if self.env.context.get('validate_analytic', False):
mandatory_plans_ids = [plan['id'] for plan in self.env['account.analytic.plan'].sudo().get_relevant_plans(**kwargs) if plan['applicability'] == 'mandatory']
mandatory_plans_ids = [plan['id'] for plan in self.env['account.analytic.plan'].sudo().with_company(self.company_id).get_relevant_plans(**kwargs) if plan['applicability'] == 'mandatory']
if not mandatory_plans_ids:
return
decimal_precision = self.env['decimal.precision'].precision_get('Percentage Analytic')
distribution_by_root_plan = {}
for analytic_account_id, percentage in (self.analytic_distribution or {}).items():
root_plan = self.env['account.analytic.account'].browse(int(analytic_account_id)).root_plan_id
distribution_by_root_plan[root_plan.id] = distribution_by_root_plan.get(root_plan.id, 0) + percentage
for analytic_account_ids, percentage in (self.analytic_distribution or {}).items():
for analytic_account in self.env['account.analytic.account'].browse(map(int, analytic_account_ids.split(","))).exists():
root_plan = analytic_account.root_plan_id
distribution_by_root_plan[root_plan.id] = distribution_by_root_plan.get(root_plan.id, 0) + percentage
for plan_id in mandatory_plans_ids:
if float_compare(distribution_by_root_plan.get(plan_id, 0), 100, precision_digits=decimal_precision) != 0:
@ -107,13 +199,77 @@ class AnalyticMixin(models.AbstractModel):
""" Normalize the float of the distribution """
if 'analytic_distribution' in vals:
vals['analytic_distribution'] = vals.get('analytic_distribution') and {
account_id: float_round(distribution, decimal_precision) for account_id, distribution in vals['analytic_distribution'].items()}
account_id: float_round(distribution, decimal_precision) if account_id != '__update__' else distribution
for account_id, distribution in vals['analytic_distribution'].items()
}
return vals
def _apply_analytic_distribution_domain(self, domain):
return [
('analytic_distribution_search', leaf[1], leaf[2])
if len(leaf) == 3 and leaf[0] == 'analytic_distribution' and isinstance(leaf[2], (str, tuple, list))
else leaf
for leaf in domain
]
def _modifiying_distribution_values(self, old_distribution, new_distribution):
fnames_to_update = set(new_distribution.pop('__update__', ()))
if old_distribution:
old_distribution.pop('__update__', None) # might be set before in `create`
project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
non_changing_plans = {
plan
for plan in project_plan + other_plans
if plan._column_name() not in fnames_to_update
}
non_changing_values = defaultdict(float)
non_changing_amount = 0
for old_key, old_val in old_distribution.items():
remaining_key = tuple(sorted(
account.id
for account in self.env['account.analytic.account'].browse(int(aid) for aid in old_key.split(','))
if account.plan_id.root_id in non_changing_plans
))
if remaining_key:
non_changing_values[remaining_key] += old_val
non_changing_amount += old_val
changing_values = defaultdict(float)
changing_amount = 0
for new_key, new_val in new_distribution.items():
remaining_key = tuple(sorted(
account.id
for account in self.env['account.analytic.account'].browse(int(aid) for aid in new_key.split(','))
if account.plan_id.root_id not in non_changing_plans
))
if remaining_key:
changing_values[remaining_key] += new_val
changing_amount += new_val
return non_changing_values, changing_values, non_changing_amount, changing_amount
def _merge_distribution(self, old_distribution: dict, new_distribution: dict) -> dict:
if '__update__' not in new_distribution:
return new_distribution # update everything by default
non_changing_values, changing_values, non_changing_amount, changing_amount = self._modifiying_distribution_values(
old_distribution,
new_distribution,
)
if non_changing_amount > changing_amount:
ratio = changing_amount / non_changing_amount
additional_vals = {
','.join(map(str, old_key)): old_val * (1 - ratio)
for old_key, old_val in non_changing_values.items()
if old_key
}
ratio = 1
elif changing_amount > non_changing_amount:
ratio = non_changing_amount / changing_amount
additional_vals = {
','.join(map(str, new_key)): new_val * (1 - ratio)
for new_key, new_val in changing_values.items()
if new_key
}
else:
ratio = 1
additional_vals = {}
return {
','.join(map(str, old_key + new_key)): ratio * old_val * new_val / non_changing_amount
for old_key, old_val in non_changing_values.items()
for new_key, new_val in changing_values.items()
} | additional_vals

View file

@ -1,8 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from random import randint
from odoo import api, fields, models, _
from random import randint
from odoo.exceptions import UserError
from odoo.tools import ormcache, make_index_name, create_index
from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
class AccountAnalyticPlan(models.Model):
@ -10,24 +16,30 @@ class AccountAnalyticPlan(models.Model):
_description = 'Analytic Plans'
_parent_store = True
_rec_name = 'complete_name'
_order = 'complete_name asc'
_check_company_auto = True
_order = 'sequence asc, id'
def _default_color(self):
return randint(1, 11)
name = fields.Char(required=True)
name = fields.Char(
required=True,
translate=True,
inverse='_inverse_name',
)
description = fields.Text(string='Description')
parent_id = fields.Many2one(
'account.analytic.plan',
string="Parent",
inverse='_inverse_parent_id',
index='btree_not_null',
ondelete='cascade',
domain="[('id', '!=', id), ('company_id', 'in', [False, company_id])]",
check_company=True,
domain="['!', ('id', 'child_of', id)]",
)
parent_path = fields.Char(
index='btree',
unaccent=False,
parent_path = fields.Char(index='btree')
root_id = fields.Many2one(
'account.analytic.plan',
compute='_compute_root_id',
search='_search_root_id',
)
children_ids = fields.One2many(
'account.analytic.plan',
@ -44,11 +56,6 @@ class AccountAnalyticPlan(models.Model):
recursive=True,
store=True,
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
account_ids = fields.One2many(
'account.analytic.account',
'plan_id',
@ -66,6 +73,7 @@ class AccountAnalyticPlan(models.Model):
'Color',
default=_default_color,
)
sequence = fields.Integer(default=10)
default_applicability = fields.Selection(
selection=[
@ -74,16 +82,61 @@ class AccountAnalyticPlan(models.Model):
('unavailable', 'Unavailable'),
],
string="Default Applicability",
required=True,
default='optional',
readonly=False,
company_dependent=True,
)
applicability_ids = fields.One2many(
'account.analytic.applicability',
'analytic_plan_id',
string='Applicability',
domain="[('company_id', '=', current_company_id)]",
)
def _auto_init(self):
super()._auto_init()
def precommit():
self.env['ir.default'].set(
self._name,
'default_applicability',
'optional',
)
self.env.cr.precommit.add(precommit)
@ormcache()
def __get_all_plans(self):
project_plan = self.browse(int(self.env['ir.config_parameter'].sudo().get_param('analytic.project_plan', 0)))
if not project_plan:
raise UserError(_("A 'Project' plan needs to exist and its id needs to be set as `analytic.project_plan` in the system variables"))
other_plans = self.sudo().search([('parent_id', '=', False)]) - project_plan
return project_plan.id, other_plans.ids
def _get_all_plans(self):
return map(self.browse, self.__get_all_plans())
def _strict_column_name(self):
self.ensure_one()
project_plan, _other_plans = self._get_all_plans()
return 'account_id' if self == project_plan else f"x_plan{self.id}_id"
def _column_name(self):
return self.root_id._strict_column_name()
def _inverse_name(self):
self._sync_all_plan_column()
def _inverse_parent_id(self):
self._sync_all_plan_column()
@api.depends('parent_id', 'parent_path')
def _compute_root_id(self):
for plan in self.sudo():
plan.root_id = int(plan.parent_path[:-1].split('/')[0]) if plan.parent_path else plan
def _search_root_id(self, operator, value):
if operator != '=':
return NotImplemented
return [('parent_path', '=like', f'{value}/%')]
@api.depends('name', 'parent_id.complete_name')
def _compute_complete_name(self):
for plan in self:
@ -99,14 +152,42 @@ class AccountAnalyticPlan(models.Model):
@api.depends('account_ids', 'children_ids')
def _compute_all_analytic_account_count(self):
# Get all children_ids from each plan
if not self.ids:
self.all_account_count = 0
return
self.env.cr.execute("""
SELECT parent.id,
array_agg(child.id) as children_ids
FROM account_analytic_plan parent
JOIN account_analytic_plan child ON child.parent_path LIKE parent.parent_path || '%%'
WHERE parent.id IN %s
GROUP BY parent.id
""", [tuple(self.ids)])
all_children_ids = dict(self.env.cr.fetchall())
plans_count = dict(
self.env['account.analytic.account']._read_group(
domain=[('plan_id', 'child_of', self.ids)],
aggregates=['id:count'],
groupby=['plan_id']
)
)
plans_count = {k.id: v for k, v in plans_count.items()}
for plan in self:
plan.all_account_count = self.env['account.analytic.account'].search_count([('plan_id', "child_of", plan.id)])
plan.all_account_count = sum(plans_count.get(child_id, 0) for child_id in all_children_ids.get(plan.id, []))
@api.depends('children_ids')
def _compute_children_count(self):
for plan in self:
plan.children_count = len(plan.children_ids)
@api.onchange('parent_id')
def _onchange_parent_id(self):
project_plan, __ = self._get_all_plans()
if self._origin.id == project_plan.id:
raise UserError(_("You cannot add a parent to the base plan '%s'", project_plan.name))
def action_view_analytical_accounts(self):
result = {
"type": "ir.actions.act_window",
@ -134,31 +215,29 @@ class AccountAnalyticPlan(models.Model):
def get_relevant_plans(self, **kwargs):
""" Returns the list of plans that should be available.
This list is computed based on the applicabilities of root plans. """
company_id = kwargs.get('company_id', self.env.company.id)
record_account_ids = kwargs.get('existing_account_ids', [])
all_plans = self.search([
('account_ids', '!=', False),
'|', ('company_id', '=', company_id), ('company_id', '=', False),
])
root_plans = self.browse({
int(plan.parent_path.split('/')[0])
for plan in all_plans
}).filtered(lambda p: p._get_applicability(**kwargs) != 'unavailable')
project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
root_plans = (project_plan + other_plans).filtered(lambda p: (
p.all_account_count > 0
and not p.parent_id
and p._get_applicability(**kwargs) != 'unavailable'
))
# If we have accounts that are already selected (before the applicability rules changed or from a model),
# we want the plans that were unavailable to be shown in the list (and in optional, because the previous
# percentage could be different from 0)
forced_plans = self.env['account.analytic.account'].browse(record_account_ids).exists().mapped(
'root_plan_id') - root_plans
return sorted([
return [
{
"id": plan.id,
"name": plan.name,
"color": plan.color,
"applicability": plan._get_applicability(**kwargs) if plan in root_plans else 'optional',
"all_account_count": plan.all_account_count
"all_account_count": plan.all_account_count,
"column_name": plan._column_name(),
}
for plan in root_plans + forced_plans
], key=lambda d: (d['applicability'], d['id']))
for plan in (root_plans + forced_plans).sorted('sequence')
]
def _get_applicability(self, **kwargs):
""" Returns the applicability of the best applicability line or the default applicability """
@ -169,31 +248,155 @@ class AccountAnalyticPlan(models.Model):
else:
score = 0
applicability = self.default_applicability
for applicability_rule in self.applicability_ids:
for applicability_rule in self.applicability_ids.filtered(
lambda rule:
not rule.company_id
or not kwargs.get('company_id')
or rule.company_id.id == kwargs.get('company_id')
):
score_rule = applicability_rule._get_score(**kwargs)
if score_rule > score:
applicability = applicability_rule.applicability
score = score_rule
return applicability
def _get_default(self):
plan = self.env['account.analytic.plan'].sudo().search(
['|', ('company_id', '=', False), ('company_id', '=', self.env.company.id)],
limit=1)
if plan:
return plan
else:
return self.env['account.analytic.plan'].create({
'name': 'Default',
'company_id': self.env.company.id,
})
def unlink(self):
# Remove the dynamic field created with the plan (see `_inverse_name`)
self._find_plan_column().unlink()
related_fields = self._find_related_field()
res = super().unlink()
related_fields.filtered(lambda f: not self._is_subplan_field_used(f)).unlink()
self.env.registry.clear_cache('stable')
return res
def _hierarchy_name(self):
depth = self.parent_path.count('/') - 1
fname = f"{self._column_name()}_{depth}"
if fname.startswith('account_id'):
fname = f'x_{fname}'
return depth, fname
def _is_subplan_field_used(self, field):
"""Return `True` if there are analytic plans still on the same hierarchy level as what the field was created for.
:param field: the recordset of a field created to group by sub plan
:rtype: bool
"""
assert '_id_' in field.name
root_name, depth = field.name.rsplit('_', maxsplit=1)
plan_id_match = re.search(r'\d+', root_name)
plan_id = int(plan_id_match.group() if plan_id_match else next(self._get_all_plans()))
return bool(self.env['account.analytic.plan'].search([
('root_id', '=', plan_id),
('parent_path', 'like', '%'.join('/' * (int(depth) + 1))),
]))
def _find_plan_column(self, model=False):
domain = [('name', 'in', [plan._strict_column_name() for plan in self])]
if model:
domain.append(('model', '=', model))
return self.env['ir.model.fields'].sudo().search(domain)
def _find_related_field(self, model=False):
domain = [('name', 'in', [plan._hierarchy_name()[1] for plan in self])]
if model:
domain.append(('model', '=', model))
return self.env['ir.model.fields'].sudo().search(domain)
def _sync_all_plan_column(self):
model_names = self.env.registry.descendants(['analytic.plan.fields.mixin'], '_inherit') - {'analytic.plan.fields.mixin'}
for model in model_names:
self._sync_plan_column(model)
def _sync_plan_column(self, model):
# Create/delete a new field/column on related models for this plan, and keep the name in sync.
# Sort by parent_path to ensure parents are processed before children
for plan in self.sorted('parent_path'):
prev_stored = plan._find_plan_column(model)
depth, name_related = plan._hierarchy_name()
prev_related = plan._find_related_field(model)
if plan.parent_id:
# If there is a parent, we just need to make sure there is a field to group by the hierarchy level
# of this plan, allowing to group by sub plan
if prev_stored:
prev_stored.with_context({MODULE_UNINSTALL_FLAG: True}).unlink()
description = f"{plan.root_id.name} ({depth})"
if not prev_related:
self.env['ir.model.fields'].with_context(update_custom_fields=True).sudo().create({
'name': name_related,
'field_description': description,
'state': 'manual',
'model': model,
'model_id': self.env['ir.model']._get_id(model),
'ttype': 'many2one',
'relation': 'account.analytic.plan',
'related': plan._column_name() + '.plan_id' + '.parent_id' * (depth - 1),
'store': False,
'readonly': True,
})
else:
prev_related.field_description = description
else:
# If there is no parent, then we need to create a new stored field as this is the root plan
if prev_related:
prev_related.with_context({MODULE_UNINSTALL_FLAG: True}).unlink()
description = plan.name
if not prev_stored:
column = plan._strict_column_name()
field = self.env['ir.model.fields'].with_context(update_custom_fields=True).sudo().create({
'name': column,
'field_description': description,
'state': 'manual',
'model': model,
'model_id': self.env['ir.model']._get_id(model),
'ttype': 'many2one',
'relation': 'account.analytic.account',
'copied': True,
'on_delete': 'restrict',
})
Model = self.env[model]
if Model._auto:
tablename = Model._table
indexname = make_index_name(tablename, column)
create_index(self.env.cr, indexname, tablename, [column], 'btree', f'{column} IS NOT NULL')
field['index'] = True
else:
prev_stored.field_description = description
if self.children_ids:
self.children_ids._sync_plan_column(model)
def write(self, vals):
new_parent = self.env['account.analytic.plan'].browse(vals.get('parent_id'))
plan2previous_parent = {plan: plan.parent_id for plan in self if plan.parent_id}
if 'parent_id' in vals and new_parent:
# Update accounts in analytic lines before _sync_plan_column() unlinks child plan's column
for plan in self:
self.env['account.analytic.account']._update_accounts_in_analytic_lines(
new_fname=new_parent._column_name(),
current_fname=plan._column_name(),
accounts=self.env['account.analytic.account'].search([('plan_id', 'child_of', plan.id)]),
)
res = super().write(vals)
if 'parent_id' in vals and not new_parent:
# Update accounts in analytic lines after _sync_plan_column() creates the new column
for plan, previous_parent in plan2previous_parent.items():
self.env['account.analytic.account']._update_accounts_in_analytic_lines(
new_fname=plan._column_name(),
current_fname=previous_parent._column_name(),
accounts=self.env['account.analytic.account'].search([('plan_id', 'child_of', plan.id)]),
)
return res
class AccountAnalyticApplicability(models.Model):
_name = 'account.analytic.applicability'
_description = "Analytic Plan's Applicabilities"
_check_company_auto = True
_check_company_domain = models.check_company_domain_parent_of
analytic_plan_id = fields.Many2one('account.analytic.plan')
analytic_plan_id = fields.Many2one('account.analytic.plan', index='btree_not_null')
business_domain = fields.Selection(
selection=[
('general', 'Miscellaneous'),
@ -209,11 +412,19 @@ class AccountAnalyticApplicability(models.Model):
required=True,
string="Applicability",
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
def _get_score(self, **kwargs):
""" Gives the score of an applicability with the parameters of kwargs """
self.ensure_one()
# 0.5 is because company is less important than other fields for an equal number of valid fields
# No company on the applicability and the kwargs together are not considered a more fitting rule
score = 0.5 if self.company_id and kwargs.get('company_id') else 0
if not kwargs.get('business_domain'):
return 0
return score
else:
return 1 if kwargs.get('business_domain') == self.business_domain else -1
return score + 1 if kwargs.get('business_domain') == self.business_domain else -1

View file

@ -0,0 +1,28 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
from odoo.exceptions import UserError
class IrConfigParameter(models.Model):
_inherit = 'ir.config_parameter'
def write(self, vals):
''' When this paramater is changed, dynamic fields needs to be recomputed '''
param = self.filtered(lambda x: x.key == 'analytic.project_plan')
if not param:
return super().write(vals)
old_plan_id = param.value
new_plan_id = vals.get('value')
if not (
new_plan_id
and str(new_plan_id).isnumeric()
and (plan := self.env['account.analytic.plan'].browse(int(new_plan_id)))
and (plan_field := plan._find_plan_column())
):
raise UserError(_('The value for %s must be the ID to a valid analytic plan that is not a subplan', param.key))
res = super().write(vals)
self.env['account.analytic.plan'].browse(int(old_plan_id))._sync_all_plan_column()
plan_field.unlink()
return res

View file

@ -3,6 +3,7 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'