diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index 95a6c8d6be7..75c09e1db4a 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -22,7 +22,10 @@ class ProductTemplate(models.Model): ('task_in_project', 'Create a task in sale order\'s project'), ('project_only', 'Create a new project but no task'), ], string="Service Tracking", default="no", - help="On Sales order confirmation, this product can generate a project and/or task. From those, you can track the service you are selling.") + help="On Sales order confirmation, this product can generate a project and/or task. \ + From those, you can track the service you are selling.\n \ + 'In sale order\'s project': Will use the sale order\'s configured project if defined or fallback to \ + creating a new project based on the selected template.") project_id = fields.Many2one( 'project.project', 'Project', company_dependent=True, domain=[('billable_type', '=', 'no')], help='Select a non billable project on which tasks can be created. This setting must be set for each company.') diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 010558c2452..c31150685c6 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -17,6 +17,11 @@ class SaleOrder(models.Model): tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', string='Tasks associated to this sale') tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user") + visible_project = fields.Boolean('Display project', compute='_compute_visible_project', readonly=True) + project_id = fields.Many2one( + 'project.project', 'Project', domain=[('billable_type', 'in', ('no', 'task_rate')), ('analytic_account_id', '!=', False)], + readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, + help='Select a non billable project on which tasks can be created.') project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user", help="Projects used in this sales order.") @api.multi @@ -39,14 +44,31 @@ class SaleOrder(models.Model): order.tasks_ids = self.env['project.task'].search([('sale_line_id', 'in', order.order_line.ids)]) order.tasks_count = len(order.tasks_ids) + @api.multi + @api.depends('order_line.product_id.service_tracking') + def _compute_visible_project(self): + """ Users should be able to select a project_id on the SO if at least one SO line has a product with its service tracking + configured as 'task_in_project' """ + for order in self: + order.visible_project = any( + service_tracking == 'task_in_project' for service_tracking in order.order_line.mapped('product_id.service_tracking') + ) + @api.multi @api.depends('order_line.product_id', 'order_line.project_id') def _compute_project_ids(self): for order in self: projects = order.order_line.mapped('product_id.project_id') projects |= order.order_line.mapped('project_id') + projects |= order.project_id order.project_ids = projects + @api.onchange('project_id') + def _onchange_project_id(self): + """ Set the SO analytic account to the selected project's analytic account """ + if self.project_id.analytic_account_id: + self.analytic_account_id = self.project_id.analytic_account_id + @api.multi def _action_confirm(self): """ On SO confirmation, some lines should generate a task or a project. """ @@ -305,6 +327,21 @@ class SaleOrderLine(models.Model): return True return False + def _determine_project(so_line): + """Determine the project for this sale order line. + Rules are different based on the service_tracking: + + - 'project_only': the project_id can only come from the sale order line itself + - 'task_in_project': the project_id comes from the sale order line only if no project_id was configured + on the parent sale order""" + + if so_line.product_id.service_tracking == 'project_only': + return so_line.project_id + elif so_line.product_id.service_tracking == 'task_in_project': + return so_line.order_id.project_id or so_line.project_id + + return False + # task_global_project: create task in global project for so_line in so_line_task_global_project: if not so_line.task_id: @@ -312,8 +349,9 @@ class SaleOrderLine(models.Model): so_line._timesheet_create_task(project=map_sol_project[so_line.id]) # project_only, task_in_project: create a new project, based or not on a template (1 per SO). May be create a task too. + # if 'task_in_project' and project_id configured on SO, use that one instead for so_line in so_line_new_project: - project = so_line.project_id + project = _determine_project(so_line) if not project and _can_create_project(so_line): project = so_line._timesheet_create_project() if so_line.product_id.project_template_id: diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py index b0cf056c746..15584e9cfaa 100644 --- a/addons/sale_timesheet/tests/common.py +++ b/addons/sale_timesheet/tests/common.py @@ -28,11 +28,16 @@ class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart): 'reconcile': True, 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, }) + cls.analytic_account_sale = cls.env['account.analytic.account'].create({ + 'name': 'Project for selling timesheet - AA', + 'code': 'AA-2030' + }) # Create projects cls.project_global = cls.env['project.project'].create({ 'name': 'Project for selling timesheets', 'allow_timesheets': True, + 'analytic_account_id': cls.analytic_account_sale.id, }) cls.project_template = cls.env['project.project'].create({ 'name': 'Project TEMPLATE for services', diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index a28ec7a01d9..eb1532d8744 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -390,6 +390,106 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): self.assertEqual(so_line2.project_id.sale_line_id, so_line2, "SO line of project should be the one that create it.") self.assertEqual(so_line5.project_id.sale_line_id, so_line5, "SO line of project with template B should be the one that create it.") + def test_sale_task_in_project_with_project(self): + """ This will test the new 'task_in_project' service tracking correctly creates tasks and projects + when a project_id is configured on the parent sale_order (ref task #1915660). + + Setup: + - Configure a project_id on the SO + - SO line 1: a product with its delivery tracking set to 'task_in_project' + - SO line 2: the same product as SO line 1 + - SO line 3: a product with its delivery tracking set to 'project_only' + - Confirm sale_order + + Expected result: + - 2 tasks created on the project_id configured on the SO + - 1 project created with the correct template for the 'project_only' product + """ + + self.sale_order.write({'project_id': self.project_global.id}) + self.sale_order._onchange_project_id() + self.assertEqual(self.sale_order.analytic_account_id, self.analytic_account_sale, "Changing the project on the SO should set the analytic account accordingly.") + + so_line1 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet3.name, + 'product_id': self.product_order_timesheet3.id, + 'product_uom_qty': 11, + 'product_uom': self.product_order_timesheet3.uom_id.id, + 'price_unit': self.product_order_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + so_line2 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet3.name, + 'product_id': self.product_order_timesheet3.id, + 'product_uom_qty': 10, + 'product_uom': self.product_order_timesheet3.uom_id.id, + 'price_unit': self.product_order_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + so_line3 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet4.name, + 'product_id': self.product_order_timesheet4.id, + 'product_uom_qty': 5, + 'product_uom': self.product_order_timesheet4.uom_id.id, + 'price_unit': self.product_order_timesheet4.list_price, + 'order_id': self.sale_order.id, + }) + + # temporary project_template_id for our checks + self.product_order_timesheet4.write({ + 'project_template_id': self.project_template.id + }) + self.sale_order.action_confirm() + # remove it after the confirm because other tests don't like it + self.product_order_timesheet4.write({ + 'project_template_id': False + }) + + self.assertTrue(so_line1.task_id, "so_line1 should create a task as its product's service_tracking is set as 'task_in_project'") + self.assertEqual(so_line1.task_id.project_id, self.project_global, "The project on so_line1's task should be project_global as configured on its parent sale_order") + self.assertTrue(so_line2.task_id, "so_line2 should create a task as its product's service_tracking is set as 'task_in_project'") + self.assertEqual(so_line2.task_id.project_id, self.project_global, "The project on so_line2's task should be project_global as configured on its parent sale_order") + self.assertFalse(so_line3.task_id.name, "so_line3 should not create a task as its product's service_tracking is set as 'project_only'") + self.assertNotEqual(so_line3.project_id, self.project_template, "so_line3 should create a new project and not directly use the configured template") + self.assertIn(self.project_template.name, so_line3.project_id.name, "The created project for so_line3 should use the configured template") + + def test_sale_task_in_project_without_project(self): + """ This will test the new 'task_in_project' service tracking correctly creates tasks and projects + when the parent sale_order does NOT have a configured project_id (ref task #1915660). + + Setup: + - SO line 1: a product with its delivery tracking set to 'task_in_project' + - Confirm sale_order + + Expected result: + - 1 project created with the correct template for the 'task_in_project' because the SO + does not have a configured project_id + - 1 task created from this new project + """ + + so_line1 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet3.name, + 'product_id': self.product_order_timesheet3.id, + 'product_uom_qty': 10, + 'product_uom': self.product_order_timesheet3.uom_id.id, + 'price_unit': self.product_order_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + + # temporary project_template_id for our checks + self.product_order_timesheet3.write({ + 'project_template_id': self.project_template.id + }) + self.sale_order.action_confirm() + # remove it after the confirm because other tests don't like it + self.product_order_timesheet3.write({ + 'project_template_id': False + }) + + self.assertTrue(so_line1.task_id, "so_line1 should create a task as its product's service_tracking is set as 'task_in_project'") + self.assertNotEqual(so_line1.project_id, self.project_template, "so_line1 should create a new project and not directly use the configured template") + self.assertIn(self.project_template.name, so_line1.project_id.name, "The created project for so_line1 should use the configured template") + def test_billable_task_and_subtask(self): """ Test if subtasks and tasks are billed on the correct SO line """ # create SO line and confirm it diff --git a/addons/sale_timesheet/views/sale_order_views.xml b/addons/sale_timesheet/views/sale_order_views.xml index 50d2233ee39..72b42ad4e29 100644 --- a/addons/sale_timesheet/views/sale_order_views.xml +++ b/addons/sale_timesheet/views/sale_order_views.xml @@ -37,6 +37,10 @@ + + + + diff --git a/addons/sale_timesheet/wizard/project_create_sale_order.py b/addons/sale_timesheet/wizard/project_create_sale_order.py index 83de5260d05..768b5da4aea 100644 --- a/addons/sale_timesheet/wizard/project_create_sale_order.py +++ b/addons/sale_timesheet/wizard/project_create_sale_order.py @@ -73,6 +73,7 @@ class ProjectCreateSalesOrder(models.TransientModel): # create SO sale_order = self.env['sale.order'].create({ + 'project_id': self.project_id.id, 'partner_id': self.partner_id.id, 'analytic_account_id': self.project_id.analytic_account_id.id, 'client_order_ref': self.project_id.name,