[FIX] sale_project,sale_timesheet: get SOLs linked to project

Before this commit, when we need to get all SOL linked to the project.
That is the SOL linked to the project and also the SOL linked to all
tasks of this project.

Technically, a fetch is made to get the SOL of the project, a fetch to
get all tasks of the project and then a fetch to get the SOL linked to
all tasks fetched, finally the union is made to avoid having duplicate
sale order items in the recordset
(`self.sale_line_id | self.tasks.sale_line_id`).

This way could be a performance issue if the project contained many
active tasks and so the project update could take more and more time to
compute the sold items shown in the right side panel of project update
view.

This commit does a query builder to fetch the SOLs linked to the project
and its active tasks to avoid doing the prefetchs.

part of task-2710808

closes odoo/odoo#85660

X-original-commit: 0734f4e
Related: odoo/enterprise#24918
Signed-off-by: Laurent Stukkens (ltu) <ltu@odoo.com>
This commit is contained in:
Xavier BOL (xbo)
2022-03-03 03:07:57 +00:00
parent 41bcee0cda
commit 8aa63d7873
5 changed files with 234 additions and 9 deletions
+83 -2
View File
@@ -5,6 +5,8 @@ from ast import literal_eval
from odoo import api, fields, models, _, _lt
from odoo.exceptions import ValidationError, AccessError
from odoo.osv import expression
from odoo.osv.query import Query
class Project(models.Model):
@@ -74,12 +76,13 @@ class Project(models.Model):
def _get_all_sales_orders(self):
self.ensure_one()
return self.sale_order_id | self.task_ids.sale_order_id | self.milestone_ids.sale_line_id.order_id
return self._fetch_sale_order_items({'project.task': ['|', ('stage_id.fold', '=', False), ('stage_id', '=', False)]}).order_id
@api.depends('sale_order_id', 'task_ids.sale_order_id')
def _compute_sale_order_count(self):
sale_order_items_per_project_id = self._fetch_sale_order_items_per_project_id({'project.task': ['|', ('stage_id.fold', '=', False), ('stage_id', '=', False)]})
for project in self:
project.sale_order_count = len(project._get_all_sales_orders())
project.sale_order_count = len(sale_order_items_per_project_id.get(project.id, self.env['sale.order.line']).order_id)
def action_view_sos(self):
self.ensure_one()
@@ -129,6 +132,84 @@ class Project(models.Model):
# Project Updates
# ----------------------------
def _fetch_sale_order_items_per_project_id(self, domain_per_model=None):
if not self:
return {}
if len(self) == 1:
return {self.id: self._fetch_sale_order_items(domain_per_model)}
query_str, params = self._get_sale_order_items_query(domain_per_model).select('id', 'ARRAY_AGG(DISTINCT sale_line_id) AS sale_line_ids')
query = f"""
{query_str}
GROUP BY id
"""
self._cr.execute(query, params)
return {row['id']: self.env['sale.order.line'].browse(row['sale_line_ids']) for row in self._cr.dictfetchall()}
def _fetch_sale_order_items(self, domain_per_model=None, limit=None, offset=None):
return self.env['sale.order.line'].browse(self._fetch_sale_order_item_ids(domain_per_model, limit, offset))
def _fetch_sale_order_item_ids(self, domain_per_model=None, limit=None, offset=None):
if not self or not self.filtered('allow_billable'):
return []
query = self._get_sale_order_items_query(domain_per_model)
query.limit = limit
query.offset = offset
query_str, params = query.select('DISTINCT sale_line_id')
self._cr.execute(query_str, params)
return [row[0] for row in self._cr.fetchall()]
def _get_sale_orders(self):
return self._get_sale_order_items().order_id
def _get_sale_order_items(self):
return self._fetch_sale_order_items()
def _get_sale_order_items_query(self, domain_per_model=None):
if domain_per_model is None:
domain_per_model = {}
billable_project = [('allow_billable', '=', True)]
project_domain = [('id', 'in', self.ids), ('sale_line_id', '!=', False)]
if 'project.project' in domain_per_model:
project_domain = expression.AND([
domain_per_model['project.project'],
project_domain,
billable_project,
])
project_query = self.env['project.project']._where_calc(project_domain)
self._apply_ir_rules(project_query, 'read')
project_query_str, project_params = project_query.select('id', 'sale_line_id')
Task = self.env['project.task']
task_domain = [('project_id', 'in', self.ids), ('sale_line_id', '!=', False)]
if Task._name in domain_per_model:
task_domain = expression.AND([
domain_per_model[Task._name],
task_domain,
billable_project,
])
task_query = Task._where_calc(task_domain)
Task._apply_ir_rules(task_query, 'read')
task_query_str, task_params = task_query.select(f'{Task._table}.project_id AS id', f'{Task._table}.sale_line_id')
ProjectMilestone = self.env['project.milestone']
milestone_domain = [('project_id', 'in', self.ids), ('allow_billable', '=', True)]
if ProjectMilestone._name in domain_per_model:
milestone_domain = expression.AND([
domain_per_model[ProjectMilestone._name],
milestone_domain,
billable_project,
])
milestone_query = ProjectMilestone._where_calc(milestone_domain)
ProjectMilestone._apply_ir_rules(milestone_query)
milestone_query_str, milestone_params = milestone_query.select(
f'{ProjectMilestone._table}.project_id AS id',
f'{ProjectMilestone._table}.sale_line_id',
)
query = Query(self._cr, 'project_sale_order_item', ' UNION '.join([project_query_str, task_query_str, milestone_query_str]))
query._where_params = project_params + task_params + milestone_params
return query
def _get_stat_buttons(self):
buttons = super(Project, self)._get_stat_buttons()
if self.user_has_groups('sales_team.group_sale_salesman_all_leads'):
+26 -1
View File
@@ -19,6 +19,7 @@ class TestSaleProject(TransactionCase):
cls.project_global = cls.env['project.project'].create({
'name': 'Global Project',
'analytic_account_id': cls.analytic_account_sale.id,
'allow_billable': True,
})
cls.project_template = cls.env['project.project'].create({
'name': 'Project TEMPLATE for services',
@@ -82,10 +83,11 @@ class TestSaleProject(TransactionCase):
})
def test_sale_order_with_project_task(self):
SaleOrder = self.env['sale.order'].with_context(tracking_disable=True)
SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True)
partner = self.env['res.partner'].create({'name': "Mur en béton"})
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
sale_order = SaleOrder.create({
'partner_id': partner.id,
'partner_invoice_id': partner.id,
'partner_shipping_id': partner.id,
@@ -127,3 +129,26 @@ class TestSaleProject(TransactionCase):
# service_tracking 'project_only'
self.assertFalse(so_line_order_only_project.task_id, "Task should not be created")
self.assertTrue(so_line_order_only_project.project_id, "Sales order line should be linked to newly created project")
self.assertEqual(self.project_global._get_sale_order_items(), self.project_global.sale_line_id | self.project_global.tasks.sale_line_id, 'The _get_sale_order_items should returns all the SOLs linked to the project and its active tasks.')
sale_order_2 = SaleOrder.create({
'partner_id': partner.id,
'partner_invoice_id': partner.id,
'partner_shipping_id': partner.id,
})
sale_line_1_order_2 = SaleOrderLine.create({
'product_id': self.product_order_service1.id,
'product_uom_qty': 10,
'product_uom': self.product_order_service1.uom_id.id,
'price_unit': self.product_order_service1.list_price,
'order_id': sale_order_2.id,
})
task = self.env['project.task'].create({
'name': 'Task',
'sale_line_id': sale_line_1_order_2.id,
'project_id': self.project_global.id,
})
self.assertEqual(task.sale_line_id, sale_line_1_order_2)
self.assertIn(task.sale_line_id, self.project_global._get_sale_order_items())
self.assertEqual(self.project_global._get_sale_orders(), sale_order | sale_order_2)
+25 -6
View File
@@ -174,11 +174,6 @@ class Project(models.Model):
], limit=1)
project.sale_line_id = sol or project.sale_line_employee_ids.sale_line_id[:1] # get the first SOL containing in the employee mappings if no sol found in the search
def _get_all_sales_orders(self):
if self.allow_billable:
return super()._get_all_sales_orders() | self.sale_line_employee_ids.sale_line_id.order_id
return self.env['sale.order']
@api.depends('sale_line_employee_ids.sale_line_id', 'allow_billable')
def _compute_sale_order_count(self):
billable_projects = self.filtered('allow_billable')
@@ -306,7 +301,7 @@ class Project(models.Model):
}
def _get_sale_order_lines(self):
sale_orders = self.sale_order_id | self.tasks.sale_order_id
sale_orders = self._get_sale_orders()
return self.env['sale.order.line'].search([('order_id', 'in', sale_orders.ids), ('is_service', '=', True), ('is_downpayment', '=', False)], order='id asc')
def _get_sold_items(self):
@@ -342,6 +337,30 @@ class Project(models.Model):
}
return sold_items
def _get_sale_order_items_query(self, domain_per_model=None):
if domain_per_model is None:
domain_per_model = {}
query = super()._get_sale_order_items_query(domain_per_model)
EmployeeMapping = self.env['project.sale.line.employee.map']
employee_mapping_domain = [('project_id', 'in', self.ids), ('project_id.allow_billable', '=', True), ('sale_line_id', '!=', False)]
if EmployeeMapping._name in domain_per_model:
employee_mapping_domain = expression.AND([
domain_per_model[EmployeeMapping._name],
employee_mapping_domain,
])
employee_mapping_query = EmployeeMapping._where_calc(employee_mapping_domain)
EmployeeMapping._apply_ir_rules(employee_mapping_query, 'read')
employee_mapping_query_str, employee_mapping_params = employee_mapping_query.select(
f'{EmployeeMapping._table}.project_id AS id',
f'{EmployeeMapping._table}.sale_line_id',
)
query._tables['project_sale_order_item'] = ' UNION '.join([
query._tables['project_sale_order_item'],
employee_mapping_query_str,
])
query._where_params += employee_mapping_params
return query
def _get_profitability_items(self):
if not self.user_has_groups('project.group_project_manager'):
return {'data': []}
+1
View File
@@ -5,6 +5,7 @@ from . import common
from . import common_reporting
from . import test_sale_timesheet
from . import test_sale_service
from . import test_project
from . import test_project_billing
from . import test_reinvoice
from . import test_reporting
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from .common import TestCommonSaleTimesheet
class TestProject(TestCommonSaleTimesheet):
def test_fetch_sale_order_items(self):
""" Test _fetch_sale_order_items and _get_sale_order_items methods
This test will check we have the SOLs linked to the project and its tasks.
Test Case:
=========
1) No SOLs and SO should be found on a non billable project
2) Sol linked to the project should be fetched
3) SOL linked to the project and its task should be fetched
4) Add a employee mapping and check the SOL of this mapping is fetched with the others.
5) remove the SOL linked to the project and check the SOL linked to the task is fetched
6) Add an additional domain in the tasks to check if we can fetch with an additional filter
for instance, only the SOLs linked to the folded tasks.
7) Set allàw_billable=False and check no SOL is found since the project is not billable.
"""
self.assertFalse(self.project_non_billable._fetch_sale_order_items())
self.assertFalse(self.project_non_billable._get_sale_order_items())
self.assertFalse(self.project_non_billable._get_sale_orders())
sale_item = self.so.order_line[0]
self.project_global.sale_line_id = sale_item
self.project_global.write({
'sale_line_id': sale_item.id,
})
self.project_global.invalidate_cache()
expected_task_sale_order_items = self.project_global.tasks.sale_line_id
expected_sale_order_items = sale_item | expected_task_sale_order_items
self.assertEqual(self.project_global._fetch_sale_order_items(), expected_sale_order_items)
self.assertEqual(self.project_global._get_sale_order_items(), expected_sale_order_items)
self.assertEqual(self.project_global._get_sale_orders(), self.so)
task = self.env['project.task'].create({
'name': 'Task with SOL',
'project_id': self.project_global.id,
'sale_line_id': self.so.order_line[1].id,
})
self.assertEqual(task.project_id, self.project_global)
self.assertEqual(task.sale_line_id, self.so.order_line[1])
self.assertEqual(task.sale_order_id, self.so)
sale_lines = self.project_global._get_sale_order_items()
self.assertEqual(sale_lines, task.sale_line_id + self.project_global.sale_line_id, 'The Sales Order Items found should be the one linked to the project and the one of project task.')
self.assertEqual(self.project_global._get_sale_orders(), self.so, 'The Sales Order fetched should be the one of the both sale_lines fetched.')
employee_mapping = self.env['project.sale.line.employee.map'].create({
'project_id': self.project_global.id,
'employee_id': self.employee_user.id,
'sale_line_id': self.so.order_line[-1].id,
})
expected_sale_order_items |= employee_mapping.sale_line_id
self.assertEqual(self.project_global._get_sale_order_items(), expected_sale_order_items)
self.assertEqual(self.project_global._get_sale_orders(), expected_sale_order_items.order_id)
self.project_global.write({
'sale_line_id': False,
})
self.project_global.invalidate_cache()
expected_task_sale_order_items |= task.sale_line_id
self.assertEqual(self.project_global._get_sale_order_items(), expected_task_sale_order_items | employee_mapping.sale_line_id)
self.assertEqual(self.project_global._get_sale_orders(), self.so)
new_stage = self.env['project.task.type'].create({
'name': 'New',
'sequence': 1,
'project_ids': [Command.set(self.project_global.ids)],
})
done_stage = self.env['project.task.type'].create({
'name': 'Done',
'sequence': 2,
'project_ids': [Command.set(self.project_global.ids)],
'fold': True,
})
task.write({
'stage_id': done_stage.id,
})
self.env['project.task.type'].flush()
self.assertEqual(self.project_global._fetch_sale_order_items({'project.task': [('stage_id.fold', '=', False)]}), employee_mapping.sale_line_id)
self.assertEqual(self.project_global._fetch_sale_order_items({'project.task': [('stage_id.fold', '=', True)]}), task.sale_line_id | employee_mapping.sale_line_id)
task2 = self.env['project.task'].create({
'name': 'Task 2',
'project_id': self.project_global.id,
'sale_line_id': sale_item.id,
'stage_id': new_stage.id,
})
self.assertEqual(self.project_global._fetch_sale_order_items({'project.task': [('stage_id.fold', '=', False)]}), task2.sale_line_id | employee_mapping.sale_line_id)
self.assertEqual(self.project_global._fetch_sale_order_items({'project.task': [('stage_id.fold', '=', True)]}), task.sale_line_id | employee_mapping.sale_line_id)
self.project_global.allow_billable = False
self.assertFalse(self.project_global._get_sale_order_items())
self.assertFalse(self.project_global._get_sale_orders())