diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py
index 53d1291df8b..61ffb11def9 100644
--- a/addons/sale_timesheet/models/sale_order.py
+++ b/addons/sale_timesheet/models/sale_order.py
@@ -41,17 +41,18 @@ class SaleOrder(models.Model):
order.tasks_count = len(order.tasks_ids)
@api.multi
- @api.depends('order_line.product_id')
+ @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')
order.project_ids = projects
@api.multi
def action_confirm(self):
""" On SO confirmation, some lines should generate a task or a project. """
result = super(SaleOrder, self).action_confirm()
- self.order_line._timesheet_service_generation()
+ self.order_line.sudo()._timesheet_service_generation()
return result
@api.multi
@@ -128,7 +129,8 @@ class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
- task_id = fields.Many2one('project.task', 'Task', index=True, help="Task generated by the sales order item")
+ project_id = fields.Many2one('project.project', 'Generated Project', index=True, help="Project generated by the sales order item")
+ task_id = fields.Many2one('project.task', 'Generated Task', index=True, help="Task generated by the sales order item")
is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.")
analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense)
@@ -177,11 +179,15 @@ class SaleOrderLine(models.Model):
# Do not generate task/project when expense SO line, but allow
# generate task with hours=0.
if line.state == 'sale' and not line.is_expense:
- line._timesheet_service_generation()
+ line.sudo()._timesheet_service_generation()
+ # if the SO line created a task, post a message on the order
+ if line.task_id:
+ msg_body = _("Task Created (%s): %s") % (line.product_id.name, line.task_id.id, line.task_id.name)
+ line.order_id.message_post(body=msg_body)
return line
###########################################
- ## Service : Project and task generation
+ # Service : Project and task generation
###########################################
def _convert_qty_company_hours(self):
@@ -192,32 +198,33 @@ class SaleOrderLine(models.Model):
planned_hours = self.product_uom_qty
return planned_hours
- def _timesheet_find_project(self):
+ @api.multi
+ def _timesheet_create_project(self):
+ """ Generate project for the given so line, and link it.
+ :param project: record of project.project in which the task should be created
+ :return task: record of the created task
+ """
self.ensure_one()
- Project = self.env['project.project']
- project = self.product_id.with_context(force_company=self.company_id.id).project_id
- if not project:
- # find the project corresponding to the analytic account of the sales order
+ account = self.order_id.analytic_account_id
+ if not account:
+ self.order_id._create_analytic_account(prefix=self.product_id.default_code or None)
account = self.order_id.analytic_account_id
- if not account:
- self.order_id._create_analytic_account(prefix=self.product_id.default_code or None)
- account = self.order_id.analytic_account_id
- project = Project.search([('analytic_account_id', '=', account.id)], limit=1)
- if not project:
- project_name = '%s (%s)' % (account.name, self.order_partner_id.ref) if self.order_partner_id.ref else account.name
- project = Project.create({
- 'name': project_name,
- 'allow_timesheets': self.product_id.service_type == 'timesheet',
- 'analytic_account_id': account.id,
- })
- # set the SO line origin if product should create project
- if not project.sale_line_id and self.product_id.service_tracking in ['task_new_project', 'project_only']:
- project.write({'sale_line_id': self.id})
+
+ # create the project or duplicate one
+ values = {
+ 'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name,
+ 'allow_timesheets': True,
+ 'analytic_account_id': account.id,
+ 'partner_id': self.order_id.partner_id.id,
+ 'sale_line_id': self.id,
+ }
+ project = self.env['project.project'].create(values)
+ # link project as generated by current so line
+ self.write({'project_id': project.id})
return project
- def _timesheet_create_task_prepare_values(self):
+ def _timesheet_create_task_prepare_values(self, project):
self.ensure_one()
- project = self._timesheet_find_project()
planned_hours = self._convert_qty_company_hours()
return {
'name': '%s:%s' % (self.order_id.name or '', self.name.split('\n')[0] or self.product_id.name),
@@ -229,66 +236,67 @@ class SaleOrderLine(models.Model):
'project_id': project.id,
'sale_line_id': self.id,
'company_id': self.company_id.id,
- 'user_id': False, # force non assigned task, as created as sudo()
+ 'user_id': False, # force non assigned task, as created as sudo()
}
@api.multi
- def _timesheet_create_task(self):
+ def _timesheet_create_task(self, project):
""" Generate task for the given so line, and link it.
-
- :return a mapping with the so line id and its linked task
- :rtype dict
+ :param project: record of project.project in which the task should be created
+ :return task: record of the created task
"""
- result = {}
- for so_line in self:
- # create task
- values = so_line._timesheet_create_task_prepare_values()
- task = self.env['project.task'].sudo().create(values)
- so_line.write({'task_id': task.id})
- # post message on SO
- msg_body = _("Task Created (%s): %s") % (so_line.product_id.name, task.id, task.name)
- so_line.order_id.message_post(body=msg_body)
- # post message on task
- task_msg = _("This task has been created from: %s (%s)") % (so_line.order_id.id, so_line.order_id.name, so_line.product_id.name)
- task.message_post(body=task_msg)
-
- result[so_line.id] = task
- return result
-
- @api.multi
- def _timesheet_find_task(self):
- """ Find the task generated by the so lines. If no task linked, it will be
- created automatically.
-
- :return a mapping with the so line id and its linked task
- :rtype dict
- """
- # one search for all so lines
- tasks = self.env['project.task'].search([('sale_line_id', 'in', self.ids)])
- task_sol_mapping = {task.sale_line_id.id: task for task in tasks}
-
- result = {}
- for so_line in self:
- # If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a new task.
- task = task_sol_mapping.get(so_line.id)
- # If not found, create one task for the so line
- if not task:
- task = so_line._timesheet_create_task()[so_line.id]
- result[so_line.id] = task
- return result
+ values = self._timesheet_create_task_prepare_values(project)
+ task = self.env['project.task'].sudo().create(values)
+ self.write({'task_id': task.id})
+ # post message on task
+ task_msg = _("This task has been created from: %s (%s)") % (self.order_id.id, self.order_id.name, self.product_id.name)
+ task.message_post(body=task_msg)
+ return task
@api.multi
def _timesheet_service_generation(self):
""" For service lines, create the task or the project. If already exists, it simply links
the existing one to the line.
+ Note: If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a
+ new project/task. This explains the searches on 'sale_line_id' on project/task. This also
+ implied if so line of generated task has been modified, we may regenerate it.
"""
- for so_line in self.filtered(lambda sol: sol.is_service):
- # create task
- if so_line.product_id.service_tracking == 'task_global_project':
- so_line._timesheet_find_task()
- # create project
- if so_line.product_id.service_tracking == 'project_only':
- so_line._timesheet_find_project()
- # create project and task
- if so_line.product_id.service_tracking == 'task_new_project':
- so_line._timesheet_find_task()
+ so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project')
+ so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_new_project'])
+
+ # search so lines from SO of current so lines having already generated their project, in order to check if the current one can
+ # create its own project, or reuse
+ map_so_project = {}
+ if so_line_new_project:
+ order_ids = self.mapped('order_id').ids
+ so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_new_project']), ('product_id.project_template_id', '=', False)])
+ map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project}
+
+ # search the global project of current SO lines, in which create their task
+ map_sol_project = {}
+ if so_line_task_global_project:
+ map_sol_project = {sol.id: sol.product_id.with_context(force_company=sol.company_id.id).project_id for sol in so_line_task_global_project}
+
+ def _can_create_project(sol):
+ if not sol.project_id:
+ if sol.order_id.id not in map_so_project:
+ return True
+ 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:
+ if map_sol_project.get(so_line.id):
+ so_line._timesheet_create_task(project=map_sol_project[so_line.id])
+
+ # project_only, task_new_project: create a new project, based or not on a template (1 per SO). May be create a task too.
+ if so_line_new_project:
+ for so_line in so_line_new_project:
+ project = so_line.project_id
+ if not project and _can_create_project(so_line):
+ project = so_line._timesheet_create_project()
+ map_so_project[so_line.order_id.id] = project
+ if so_line.product_id.service_tracking == 'task_new_project':
+ if not project:
+ project = map_so_project[so_line.order_id.id]
+ so_line._timesheet_create_task(project=project)
diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py
index 212fa2b550c..588480a56a7 100644
--- a/addons/sale_timesheet/tests/test_sale_timesheet.py
+++ b/addons/sale_timesheet/tests/test_sale_timesheet.py
@@ -56,9 +56,11 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
so_line_ordered_global_project.product_id_change()
sale_order.action_confirm()
task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_global_project.id)])
+ project_serv1 = self.env['project.project'].search([('sale_line_id', '=', so_line_ordered_project_only.id)])
self.assertEqual(sale_order.tasks_count, 1, "One task should have been created on SO confirmation")
- self.assertTrue(sale_order.project_ids, "A project should have been created by the SO, when confirmed.")
+ self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 2 'task in global project'")
+ self.assertEqual(sale_order.analytic_account_id, project_serv1.analytic_account_id, "The created project should be linked to the analytic account of the SO")
# create invoice
sale_order.action_invoice_create()
@@ -102,7 +104,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
})
self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
- # add so line with produdct "create task in new project". (project will be the one from SO)
+ # add so line with produdct "create task in new project".
so_line_ordered_task_new_project = self.env['sale.order.line'].create({
'name': self.product_order_timesheet3.name,
'product_id': self.product_order_timesheet3.id,
@@ -111,17 +113,16 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
'price_unit': self.product_order_timesheet3.list_price,
'order_id': sale_order.id,
})
- task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_task_new_project.id)])
- project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_ordered_project_only.id)])
+
self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: Adding a new service line (so line) should put the SO in "to invocie" state.')
self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation")
+ self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'new task in new project' product, since it reuse the one from 'project only'.")
# create invoice
invoice_id = sale_order.action_invoice_create()
invoice = self.env['account.invoice'].browse(invoice_id)
self.assertEqual(len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO")
self.assertTrue(float_is_zero(invoice.amount_total - so_line_ordered_task_new_project.price_unit * 3, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong')
- self.assertEqual(project_serv2, task_serv3.project_id, "When creating task in new project, the task should be in SO project (if already exists), otherwise it created one.")
def test_timesheet_delivery(self):
""" Test timesheet invoicing with 'invoice on delivery' timetracked products
@@ -160,22 +161,24 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
# confirm SO
sale_order.action_confirm()
- task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
- task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
- project_serv3 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
+ task_serv1 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
+ task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
+ project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
- self.assertEqual(task_serv2.project_id, self.project_global, "Sale Timesheet: task should be created in global project")
- self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in global project")
- self.assertTrue(task_serv3, "Sale Timesheet: on SO confirmation, a task should have been created in a new project")
+ self.assertEqual(task_serv1.project_id, self.project_global, "Sale Timesheet: task should be created in global project")
+ self.assertTrue(task_serv1, "Sale Timesheet: on SO confirmation, a task should have been created in global project")
+ self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in a new project")
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation')
- self.assertEqual(sale_order.analytic_account_id, task_serv3.project_id.analytic_account_id, "SO should have create a project")
+ self.assertEqual(sale_order.analytic_account_id, task_serv2.project_id.analytic_account_id, "SO should have create a project")
self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation")
+ self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 1 'task in global project'")
+ self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO")
# let's log some timesheets
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': task_serv2.project_id.id, # global project
- 'task_id': task_serv2.id,
+ 'project_id': task_serv1.project_id.id, # global project
+ 'task_id': task_serv1.id,
'unit_amount': 10.5,
'employee_id': self.employee_manager.id,
})
@@ -191,8 +194,8 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
# log some timesheets again
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': task_serv2.project_id.id, # global project
- 'task_id': task_serv2.id,
+ 'project_id': task_serv1.project_id.id, # global project
+ 'task_id': task_serv1.id,
'unit_amount': 39.5,
'employee_id': self.employee_user.id,
})
@@ -214,12 +217,12 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
'price_unit': self.product_delivery_timesheet4.list_price,
'order_id': sale_order.id,
})
- self.assertEqual(project_serv3, task_serv3.project_id, "SO should not have create a second project, since so line 3 already create one project for the SO")
+ self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'project only' product, since it reuse the one from 'new task in new project'.")
# let's log some timesheets on the project
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': project_serv3.id, # global project
+ 'project_id': project_serv2.id, # global project
'unit_amount': 7,
'employee_id': self.employee_user.id,
})
@@ -258,6 +261,10 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
self.assertTrue(sale_order.project_ids, "Sales Order should have create a project")
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: manually product should not need to be invoiced on so confirmation')
+ project_serv2 = so_line_manual_only_project.project_id
+ self.assertTrue(project_serv2, "A second project is created when selling 'project only' after SO confirmation.")
+ self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO")
+
# let's log some timesheets (on task and project)
timesheet1 = self.env['account.analytic.line'].create({
'name': 'Test Line',
@@ -274,6 +281,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
'employee_id': self.employee_manager.id,
})
+ self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one coming from SO line 1 'task in global project'.")
self.assertEqual(so_line_manual_global_project.task_id.sale_line_id, so_line_manual_global_project, "Task from a milestone product should be linked to its SO line too")
self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Milestone timesheet goes in billable fixed category")
self.assertTrue(float_is_zero(so_line_manual_global_project.qty_delivered, precision_digits=2), "Milestone Timesheeting should not incremented the delivered quantity on the SO line")