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())