From 8aa63d78739ec8cd3143b25cca692d02639f3d04 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Wed, 2 Mar 2022 13:09:28 +0000 Subject: [PATCH] [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) --- addons/sale_project/models/project.py | 85 +++++++++++++++- .../sale_project/tests/test_sale_project.py | 27 ++++- addons/sale_timesheet/models/project.py | 31 ++++-- addons/sale_timesheet/tests/__init__.py | 1 + addons/sale_timesheet/tests/test_project.py | 99 +++++++++++++++++++ 5 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 addons/sale_timesheet/tests/test_project.py diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index a4b45263170..92286cf0ab4 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -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'): diff --git a/addons/sale_project/tests/test_sale_project.py b/addons/sale_project/tests/test_sale_project.py index 326eb31ab2d..1371f009e29 100644 --- a/addons/sale_project/tests/test_sale_project.py +++ b/addons/sale_project/tests/test_sale_project.py @@ -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) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 85f3512bb77..ec917358395 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -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': []} diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index fe85b799a05..4f7a8fad7cf 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -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 diff --git a/addons/sale_timesheet/tests/test_project.py b/addons/sale_timesheet/tests/test_project.py new file mode 100644 index 00000000000..e15bc9bd0b1 --- /dev/null +++ b/addons/sale_timesheet/tests/test_project.py @@ -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())