[REF] sale_timesheet: project/task generation from SO
When confirming a SO, only one project will be created, even if some so lines would create a project. Only the first line will create the project, the other will not be linked to its. `project_id` field is added on so line, to keep track of the generated project, as already done for the task. The '_timesheet_service_generation' method has been rewritten to be execute in batch and improve performance and prefetching. On SO confirmation, no message are now post in chatter to avoid having one message per so line that should have created a task. A message is only posted when user manually add SO lines on confirmed SO.
This commit is contained in:
@@ -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): <a href=# data-oe-model=project.task data-oe-id=%d>%s</a>") % (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): <a href=# data-oe-model=project.task data-oe-id=%d>%s</a>") % (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: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%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: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user