[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:
jem-odoo
2018-04-06 14:30:47 +02:00
parent 9774b78852
commit 236a7011b8
2 changed files with 112 additions and 96 deletions
+86 -78
View File
@@ -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")