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,