mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 08:52:01 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue