[FIX] account, sale, project, hr, purchase: adding missing updates to analytic accounting

Steps to reproduce issue:

1. Have Sales installed
2. Enable Analytic Accounting and create two Projects
3. Go into both Project Settings and add distinct Analytic Accounts
4. Create a Vendor Bill
5. Add a Product Line and put both accounts in the Analytic Distribution
6. Add an account in another column to one of the existing Analytic Distribution Lines
7. Go to Projects and open the Project Updates of the Project whose account was alone in the Analytic Distribution Line
8. You get the error:

>     invalid literal for int() with base 10: '[int],[int]'

Explanation:

With commit odoo@dc696c8ed4850a83b6af721002a90f2944891923, the way IDs are stored changed. They are now computed in a csv string.
A part of the Analytic Accounting pipeline didn't get updated after that change, making a lot of the features not working properly.

Suggested fix:

- We must take into account the multiple analytic plans in any order possible in the ID search.
- Because the amount of `account.move.line` can increase rapidly, the new ID search must be indexable.
- We reworked the counting query to avoid `account.move` being counted multiple times.

opw-3626619

closes odoo/odoo#147664

Related: odoo/enterprise#54311
Signed-off-by: William André (wan) <wan@odoo.com>
This commit is contained in:
Paul Stroobant
2024-03-12 16:59:03 +00:00
parent 554ae1bcda
commit 73fa88c44a
10 changed files with 255 additions and 60 deletions
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.tools import SQL
class AccountAnalyticAccount(models.Model):
@@ -24,18 +25,24 @@ class AccountAnalyticAccount(models.Model):
('move_id.move_type', 'in', sale_types),
])
query.add_where(
'account_move_line.analytic_distribution ?| %s',
[[str(account_id) for account_id in self.ids]],
SQL(
"%s && %s",
[str(account_id) for account_id in self.ids],
self.env['account.move.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select(
'jsonb_object_keys(account_move_line.analytic_distribution) as account_id',
'COUNT(DISTINCT(account_move_line.move_id)) as move_count',
r"""DISTINCT move_id, (regexp_matches(jsonb_object_keys(account_move_line.analytic_distribution), '\d+', 'g'))[1]::int as account_id"""
)
query_string = f"{query_string} GROUP BY jsonb_object_keys(account_move_line.analytic_distribution)"
query_string = f"""
SELECT account_id, count(move_id) FROM
({query_string}) distribution
GROUP BY account_id
"""
self._cr.execute(query_string, query_param)
data = {int(record.get('account_id')): record.get('move_count') for record in self._cr.dictfetchall()}
data = {res['account_id']: res['count'] for res in self._cr.dictfetchall()}
for account in self:
account.invoice_count = data.get(account.id, 0)
@@ -48,25 +55,37 @@ class AccountAnalyticAccount(models.Model):
('move_id.move_type', 'in', purchase_types),
])
query.add_where(
'account_move_line.analytic_distribution ?| %s',
[[str(account_id) for account_id in self.ids]],
SQL(
"%s && %s",
[str(account_id) for account_id in self.ids],
self.env['account.move.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select(
'jsonb_object_keys(account_move_line.analytic_distribution) as account_id',
'COUNT(DISTINCT(account_move_line.move_id)) as move_count',
r"""DISTINCT move_id, (regexp_matches(jsonb_object_keys(account_move_line.analytic_distribution), '\d+', 'g'))[1]::int as account_id"""
)
query_string = f"{query_string} GROUP BY jsonb_object_keys(account_move_line.analytic_distribution)"
query_string = f"""
SELECT account_id, count(move_id) FROM
({query_string}) distribution
GROUP BY account_id
"""
self._cr.execute(query_string, query_param)
data = {int(record.get('account_id')): record.get('move_count') for record in self._cr.dictfetchall()}
data = {res['account_id']: res['count'] for res in self._cr.dictfetchall()}
for account in self:
account.vendor_bill_count = data.get(account.id, 0)
def action_view_invoice(self):
self.ensure_one()
query = self.env['account.move.line']._search([('move_id.move_type', 'in', self.env['account.move'].get_sale_types())])
query.add_where('analytic_distribution ? %s', [str(self.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.id)],
self.env['account.move.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select('DISTINCT account_move_line.move_id')
self._cr.execute(query_string, query_param)
move_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
@@ -83,7 +102,13 @@ class AccountAnalyticAccount(models.Model):
def action_view_vendor_bill(self):
self.ensure_one()
query = self.env['account.move.line']._search([('move_id.move_type', 'in', self.env['account.move'].get_purchase_types())])
query.add_where('analytic_distribution ? %s', [str(self.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.id)],
self.env['account.move.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select('DISTINCT account_move_line.move_id')
self._cr.execute(query_string, query_param)
move_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
@@ -29,6 +29,13 @@ class TestAccountAnalyticAccount(AccountTestInvoicingCommon):
'company_id': False,
})
cls.cross_plan = cls.env['account.analytic.plan'].create({'name': 'Cross'})
cls.analytic_account_c = cls.env['account.analytic.account'].create({
'name': 'analytic_account_c',
'plan_id': cls.cross_plan.id,
'company_id': False,
})
def create_invoice(self, partner, product):
return self.env['account.move'].create([{
'move_type': 'out_invoice',
@@ -210,3 +217,50 @@ class TestAccountAnalyticAccount(AccountTestInvoicingCommon):
invoice.invoice_line_ids.analytic_distribution = {self.analytic_account_b.id: 0.9}
invoice.action_post()
self.assertEqual(invoice.state, 'posted')
def test_cross_analytics_computing(self):
out_invoice = self.env['account.move'].create([{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [Command.create({
'product_id': self.product_b.id,
'price_unit': 200.0,
'analytic_distribution': {
f'{self.analytic_account_a.id},{self.analytic_account_c.id}': 20,
f'{self.analytic_account_a.id},{self.analytic_account_b.id}': 80,
},
})]
}])
out_invoice.action_post()
in_invoice = self.env['account.move'].create([{
'move_type': 'in_invoice',
'partner_id': self.partner_b.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'price_unit': 200.0,
'analytic_distribution': {
f'{self.analytic_account_a.id},{self.analytic_account_b.id}': 100,
},
}),
Command.create({
'product_id': self.product_a.id,
'price_unit': 200.0,
'analytic_distribution': {
f'{self.analytic_account_a.id},{self.analytic_account_c.id}': 50,
self.analytic_account_b.id: 50,
},
})
]
}])
in_invoice.action_post()
self.analytic_account_a._compute_invoice_count()
self.assertEqual(self.analytic_account_a.invoice_count, 1)
self.analytic_account_a._compute_vendor_bill_count()
self.assertEqual(self.analytic_account_a.vendor_bill_count, 1)
@@ -2,6 +2,7 @@
# 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
@@ -38,15 +39,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():
+28 -12
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api, _
from odoo.tools import SQL
from odoo.tools.float_utils import float_round, float_compare
from odoo.exceptions import UserError, ValidationError
@@ -29,9 +30,9 @@ class AnalyticMixin(models.AbstractModel):
WHERE table_name=%s '''
self.env.cr.execute(query, [self._table])
if self.env.cr.dictfetchone() and self._fields['analytic_distribution'].store:
query = f"""
CREATE INDEX IF NOT EXISTS {self._table}_analytic_distribution_gin_index
ON {self._table} USING gin(analytic_distribution);
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()
@@ -48,18 +49,33 @@ class AnalyticMixin(models.AbstractModel):
pass
def _search_analytic_distribution(self, operator, value):
if operator not in ['=', '!=', 'ilike', 'not ilike'] or not isinstance(value, (str, bool)):
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'
else:
raise UserError(_('Operation not supported'))
operator_name_search = '=' if operator in ('=', '!=') else 'ilike'
account_ids = list(self.env['account.analytic.account']._name_search(name=value, operator=operator_name_search))
query = f"""
query = SQL(
fr"""
SELECT id
FROM {self._table}
WHERE analytic_distribution ?| array[%s]
"""
operator_inselect = 'inselect' if operator in ('=', 'ilike') else 'not inselect'
return [('id', operator_inselect, (query, [[str(account_id) for account_id in account_ids]]))]
WHERE %s && %s
""",
[str(account_id) for account_id in account_ids],
self._query_analytic_accounts(),
)
return [('id', operator_inselect, query)]
def _query_analytic_accounts(self, table=False):
return SQL(
r"""regexp_split_to_array(jsonb_path_query_array(%s.analytic_distribution, '$.keyvalue()."key"')::text, '\D+')""",
SQL(table or self._table),
)
@api.model
def _search(self, domain, offset=0, limit=None, order=None, access_rights_uid=None):
@@ -109,6 +125,6 @@ class AnalyticMixin(models.AbstractModel):
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) else leaf
('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
]
+12 -5
View File
@@ -2,6 +2,7 @@
# 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
@@ -28,11 +29,17 @@ class AccountAnalyticAccount(models.Model):
@api.ondelete(at_uninstall=False)
def _unlink_except_account_in_analytic_distribution(self):
self.env.cr.execute("""
SELECT id FROM hr_expense
WHERE analytic_distribution::jsonb ?| array[%s]
LIMIT 1
""", ([str(id) for id in self.ids],))
self.env.cr.execute(
SQL(
r"""
SELECT id FROM hr_expense
WHERE %s && %s
LIMIT 1
""",
[str(id) for id in self.ids],
self.env['hr.expense']._query_analytic_accounts(),
)
)
expense_ids = self.env.cr.fetchall()
if expense_ids:
raise UserError(_("You cannot delete an analytic account that is used in an expense."))
@@ -5,6 +5,7 @@ import json
from collections import defaultdict
from odoo import models, _lt
from odoo.tools import SQL
from odoo.tools.misc import OrderedSet
@@ -29,7 +30,13 @@ class Project(models.Model):
""" This method is used in sale_project and project_purchase. Since project_account is the only common module (except project), we create the method here. """
# calculate the cost of bills without a purchase order
query = self.env['account.move.line'].sudo()._search(domain)
query.add_where('account_move_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.analytic_account_id.id)],
self.env['account.move.line'].sudo()._query_analytic_accounts(),
)
)
# account_move_line__move_id is the alias of the joined table account_move in the query
# we can use it, because of the "move_id.move_type" clause in the domain of the query, which generates the join
# this is faster than a search_read followed by a browse on the move_id to retrieve the move_type of each account.move.line
@@ -45,7 +52,11 @@ class Project(models.Model):
price_subtotal = self.env['res.currency'].browse(moves_read['currency_id']).with_prefetch(currency_ids)._convert(
from_amount=moves_read['price_subtotal'], to_currency=self.currency_id,
)
analytic_contribution = moves_read['analytic_distribution'][str(self.analytic_account_id.id)] / 100.
# an analytic account can appear several time in an analytic distribution with different repartition percentage
analytic_contribution = sum(
percentage for ids, percentage in moves_read['analytic_distribution'].items()
if str(self.analytic_account_id.id) in ids.split(',')
) / 100.
move_ids.add(moves_read['move_id'])
if moves_read['parent_state'] == 'draft':
if moves_read['move_type'] == 'in_invoice':
@@ -4,6 +4,7 @@ import json
from odoo import api, fields, models, _, _lt
from odoo.osv import expression
from odoo.tools import SQL
from collections import defaultdict
class Project(models.Model):
@@ -17,15 +18,24 @@ class Project(models.Model):
self.expenses_count = 0
return
query = self.env['hr.expense']._search([])
query.add_where('hr_expense.analytic_distribution ?| %s', [[str(account_id) for account_id in self.analytic_account_id.ids]])
query.add_where(
SQL(
"%s && %s",
[str(account_id) for account_id in self.analytic_account_id.ids],
self.env['hr.expense']._query_analytic_accounts(),
)
)
query_string, query_param = query.select(
'jsonb_object_keys(analytic_distribution) as account_id',
'COUNT(DISTINCT(id)) as expense_count',
r"""DISTINCT id, (regexp_matches(jsonb_object_keys(hr_expense.analytic_distribution), '\d+', 'g'))[1]::int as account_id"""
)
query_string = f'{query_string} GROUP BY jsonb_object_keys(analytic_distribution)'
query_string = f"""
SELECT account_id, count(id) FROM
({query_string}) distribution
GROUP BY account_id
"""
self._cr.execute(query_string, query_param)
data = {int(record.get('account_id')): record.get('expense_count') for record in self._cr.dictfetchall()}
data = {res['account_id']: res['count'] for res in self._cr.dictfetchall()}
for project in self:
project.expenses_count = data.get(project.analytic_account_id.id, 0)
@@ -88,7 +98,13 @@ class Project(models.Model):
return {}
can_see_expense = with_action and self.user_has_groups('hr_expense.group_hr_expense_team_approver')
query = self.env['hr.expense']._search([('state', 'in', ['approved', 'done'])])
query.add_where('hr_expense.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.analytic_account_id.id)],
self.env['hr.expense']._query_analytic_accounts(),
)
)
query_string, query_param = query.select('currency_id', 'array_agg(id) as ids', 'SUM(untaxed_amount_currency) as untaxed_amount')
query_string = f"{query_string} GROUP BY currency_id"
self._cr.execute(query_string, query_param)
@@ -5,6 +5,7 @@ import json
from odoo import api, fields, models, _, _lt
from odoo.osv import expression
from odoo.tools import SQL
from odoo.tools.misc import OrderedSet
@@ -19,16 +20,25 @@ class Project(models.Model):
self.purchase_orders_count = 0
return
query = self.env['purchase.order.line']._search([])
query.add_where('purchase_order_line.analytic_distribution ?| %s', [[str(account_id) for account_id in self.analytic_account_id.ids]])
query.add_where(
SQL(
"%s && %s",
[str(account_id) for account_id in self.analytic_account_id.ids],
self.env['purchase.order.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select(
'jsonb_object_keys(purchase_order_line.analytic_distribution) as account_id',
'COUNT(DISTINCT(order_id)) as purchase_order_count',
r"""DISTINCT order_id, (regexp_matches(jsonb_object_keys(purchase_order_line.analytic_distribution), '\d+', 'g'))[1]::int as account_id"""
)
query_string = f"{query_string} GROUP BY jsonb_object_keys(purchase_order_line.analytic_distribution)"
query_string = f"""
SELECT account_id, count(order_id) FROM
({query_string}) distribution
GROUP BY account_id
"""
self._cr.execute(query_string, query_param)
data = {int(record.get('account_id')): record.get('purchase_order_count') for record in self._cr.dictfetchall()}
data = {res['account_id']: res['count'] for res in self._cr.dictfetchall()}
for project in self:
project.purchase_orders_count = data.get(project.analytic_account_id.id, 0)
@@ -38,7 +48,13 @@ class Project(models.Model):
def action_open_project_purchase_orders(self):
query = self.env['purchase.order.line']._search([])
query.add_where('purchase_order_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.analytic_account_id.id)],
self.env['purchase.order.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select('order_id')
self._cr.execute(query_string, query_param)
purchase_order_ids = [pol.get('order_id') for pol in self._cr.dictfetchall()]
@@ -129,7 +145,13 @@ class Project(models.Model):
('qty_invoiced', '>', 0),
'|', ('qty_to_invoice', '>', 0), ('product_uom_qty', '>', 0),
], order=self.env['purchase.order.line']._order)
query.add_where('purchase_order_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.analytic_account_id.id)],
self.env['purchase.order.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select('"purchase_order_line".id', 'qty_invoiced', 'qty_to_invoice', 'product_uom_qty', 'price_unit', 'purchase_order_line.currency_id', '"purchase_order_line".analytic_distribution')
self._cr.execute(query_string, query_param)
purchase_order_line_read = [{
@@ -4,6 +4,7 @@
import json
from odoo import models, fields
from odoo.tools import SQL
from collections import defaultdict
@@ -15,7 +16,13 @@ class Project(models.Model):
return {}
can_see_expense = with_action and self.user_has_groups('hr_expense.group_hr_expense_team_approver')
query = self.env['hr.expense']._search([('state', 'in', ['approved', 'done'])])
query.add_where('hr_expense.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.analytic_account_id.id)],
self.env['hr.expense']._query_analytic_accounts(),
)
)
query_string, query_param = query.select('sale_order_id', 'product_id', 'currency_id', 'array_agg(id) as ids', 'SUM(untaxed_amount_currency) as untaxed_amount_currency')
query_string = f"{query_string} GROUP BY sale_order_id, product_id, currency_id"
self._cr.execute(query_string, query_param)
+40 -9
View File
@@ -100,15 +100,24 @@ class Project(models.Model):
def _compute_invoice_count(self):
query = self.env['account.move.line']._search([('move_id.move_type', 'in', ['out_invoice', 'out_refund'])])
query.add_where('analytic_distribution ?| %s', [[str(project.analytic_account_id.id) for project in self]])
query.add_where(
SQL(
"%s && %s",
[str(project.analytic_account_id.id) for project in self],
self.env['account.move.line']._query_analytic_accounts(),
)
)
query.order = None
query_string, query_param = query.select(
'jsonb_object_keys(account_move_line.analytic_distribution) as account_id',
'COUNT(DISTINCT move_id) as move_count',
r"""DISTINCT move_id, (regexp_matches(jsonb_object_keys(account_move_line.analytic_distribution), '\d+', 'g'))[1]::int as account_id"""
)
query_string = f"{query_string} GROUP BY jsonb_object_keys(account_move_line.analytic_distribution)"
query_string = f"""
SELECT account_id, count(move_id) FROM
({query_string}) distribution
GROUP BY account_id
"""
self._cr.execute(query_string, query_param)
data = {int(row.get('account_id')): row.get('move_count') for row in self._cr.dictfetchall()}
data = {res['account_id']: res['count'] for res in self._cr.dictfetchall()}
for project in self:
project.invoice_count = data.get(project.analytic_account_id.id, 0)
@@ -226,7 +235,13 @@ class Project(models.Model):
def action_open_project_invoices(self):
query = self.env['account.move.line']._search([('move_id.move_type', 'in', ['out_invoice', 'out_refund'])])
query.add_where('analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.analytic_account_id.id)],
self.env['account.move.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select('DISTINCT move_id')
self._cr.execute(query_string, query_param)
invoice_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
@@ -558,7 +573,13 @@ class Project(models.Model):
query = self.env['account.move.line'].sudo()._search(
self._get_revenues_items_from_invoices_domain([('id', 'not in', excluded_move_line_ids)]),
)
query.add_where('account_move_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.analytic_account_id.id)],
self.env['account.move.line']._query_analytic_accounts(),
)
)
# account_move_line__move_id is the alias of the joined table account_move in the query
# we can use it, because of the "move_id.move_type" clause in the domain of the query, which generates the join
# this is faster than a search_read followed by a browse on the move_id to retrieve the move_type of each account.move.line
@@ -572,7 +593,11 @@ class Project(models.Model):
for moves_read in invoices_move_line_read:
currency = self.env['res.currency'].browse(moves_read['currency_id']).with_prefetch(currency_ids)
price_subtotal = currency._convert(moves_read['price_subtotal'], self.currency_id, self.company_id)
analytic_contribution = moves_read['analytic_distribution'][str(self.analytic_account_id.id)] / 100.
# an analytic account can appear several time in an analytic distribution with different repartition percentage
analytic_contribution = sum(
percentage for ids, percentage in moves_read['analytic_distribution'].items()
if str(self.analytic_account_id.id) in ids.split(',')
) / 100.
move_ids.add(moves_read['move_id'])
if moves_read['parent_state'] == 'draft':
if moves_read['move_type'] == 'out_invoice':
@@ -719,7 +744,13 @@ class Project(models.Model):
def action_open_project_vendor_bills(self):
query = self.env['account.move.line']._search([('move_id.move_type', 'in', ['in_invoice', 'in_refund'])])
query.add_where('analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.add_where(
SQL(
"%s && %s",
[str(self.analytic_account_id.id)],
self.env['account.move.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select('DISTINCT move_id')
self._cr.execute(query_string, query_param)
vendor_bill_ids = [line.get('move_id') for line in self._cr.dictfetchall()]