Skip to content
Permalink
Browse files

[MERGE][IMP] sale_timesheet: select project on Sale Order to create task

Purpose
=======

The 'service_tracking' selection field on the 'product.template' model in the 'sale_timesheet' module
had its 'task_new_project' option renamed to 'task_in_project'.

If one or more sale_order_line have a product configured with this option, a new 'project_id' field appears
on the sale_order under the 'Sales Information' section.
When a project_id is selected, the analytic account on the sale order is automatically changed
to this project's analytic account.

This allows handling more use cases on sale_order:
- If a sale_order_line has a service product with that new option and no project_id configured on the sale_order:
  -> create a new project based on the configured project_template_id and then create a task in that project
     (same behavior as before)
- If a sale_order_line has a service product with that new option and a project_id is configured on the sale_order:
  -> directly create a task in the selected project_id

The behavior does not change for the 3 other options ('no', 'task_global_project' and 'project_only').

Specs
=======
- Add project field on sale order in "other infos" tab, shows only when a product on the SO has the option activated
- If projec is selected on SO, create task in this project, otherwise it creates a new project (as before)
- Rename 'task_new_project' into "task_in_project"
- Allow to have the new option with project template
- Selectable projects on Sales Order are `billate_type` in 'no' and 'task_rate'
- Never link SO's project to SO or AA
- Add onchange on project_id to select this project's analytic account on the SO
- Non stored computed field to check if at least one SOL has the new option

Use cases
- 2 SOL with new option (no template) will create 2 tasks in SO's project
- 2 SOL with new options (one no template, one with) + 1 SOL "create new P no T" --> create 2 tasks in SO's project and one new project

Task #1915660

closes #29772

Signed-off-by: Jérome Maes (jem) <jem@openerp.com>
  • Loading branch information...
robodoo committed Mar 14, 2019
2 parents 7282a35 + 0af717b commit 1191579a6fe22e6361d829a2497fbc92749147f1
@@ -46,7 +46,7 @@
<field name="allow_timesheets"/>
</xpath>
<xpath expr="//field[@name='partner_id']" position="after">
<field name="analytic_account_id"/>
<field name="analytic_account_id" groups="analytic.group_analytic_accounting" />
</xpath>
</field>
</record>
@@ -74,7 +74,7 @@
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="uom_po_id" ref="uom.product_uom_hour"/>
<field name="service_policy">delivered_timesheet</field>
<field name="service_tracking">task_new_project</field>
<field name="service_tracking">task_in_project</field>
</record>

<record id="product_service_deliver_timesheet_2" model="product.product">
@@ -87,7 +87,7 @@
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="uom_po_id" ref="uom.product_uom_hour"/>
<field name="service_policy">delivered_timesheet</field>
<field name="service_tracking">task_new_project</field>
<field name="service_tracking">task_in_project</field>
</record>

<record id="product_service_deliver_manual" model="product.product">
@@ -19,10 +19,13 @@ class ProductTemplate(models.Model):
service_tracking = fields.Selection([
('no', 'Don\'t create task'),
('task_global_project', 'Create a task in an existing project'),
('task_new_project', 'Create a task in a new project'),
('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.")
], 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.\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.')
@@ -73,7 +76,7 @@ def _check_project_and_template(self):
raise ValidationError(_('The product %s should not have a project nor a project template since it will not generate project.') % (product.name,))
elif product.service_tracking == 'task_global_project' and product.project_template_id:
raise ValidationError(_('The product %s should not have a project template since it will generate a task in a global project.') % (product.name,))
elif product.service_tracking in ['task_new_project', 'project_only'] and product.project_id:
elif product.service_tracking in ['task_in_project', 'project_only'] and product.project_id:
raise ValidationError(_('The product %s should not have a global project since it will generate a project.') % (product.name,))

@api.onchange('service_tracking')
@@ -83,7 +86,7 @@ def _onchange_service_tracking(self):
self.project_template_id = False
elif self.service_tracking == 'task_global_project':
self.project_template_id = False
elif self.service_tracking in ['task_new_project', 'project_only']:
elif self.service_tracking in ['task_in_project', 'project_only']:
self.project_id = False

@api.onchange('type')
@@ -107,5 +110,5 @@ def _onchange_service_tracking(self):
self.project_template_id = False
elif self.service_tracking == 'task_global_project':
self.project_template_id = False
elif self.service_tracking in ['task_new_project', 'project_only']:
elif self.service_tracking in ['task_in_project', 'project_only']:
self.project_id = False
@@ -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 @@ def _compute_tasks_ids(self):
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. """
@@ -280,16 +302,16 @@ def _timesheet_service_generation(self):
implied if so line of generated task has been modified, we may regenerate it.
"""
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'])
so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_in_project'])

# search so lines from SO of current so lines having their project generated, in order to check if the current one can
# create its own project, or reuse the one of its order.
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)])
so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '=', False)])
map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project}
so_lines_with_project_templates = 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)])
so_lines_with_project_templates = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '!=', False)])
map_so_project_templates = {(sol.order_id.id, sol.product_id.project_template_id.id): sol.project_id for sol in so_lines_with_project_templates}

# search the global project of current SO lines, in which create their task
@@ -305,22 +327,38 @@ def _can_create_project(sol):
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:
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.
# 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:
map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project
else:
map_so_project[so_line.order_id.id] = project
if so_line.product_id.service_tracking == 'task_new_project':
if so_line.product_id.service_tracking == 'task_in_project':
if not project:
if so_line.product_id.project_template_id:
project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)]
@@ -28,11 +28,16 @@ def setUpServiceProducts(cls):
'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',
@@ -88,7 +93,7 @@ def setUpServiceProducts(cls):
'uom_po_id': uom_hour.id,
'default_code': 'SERV-ORDERED3',
'service_type': 'timesheet',
'service_tracking': 'task_new_project',
'service_tracking': 'task_in_project',
'project_id': False, # will create a project
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
@@ -166,7 +171,7 @@ def setUpServiceProducts(cls):
'uom_po_id': uom_hour.id,
'default_code': 'SERV-DELI3',
'service_type': 'timesheet',
'service_tracking': 'task_new_project',
'service_tracking': 'task_in_project',
'project_id': False, # will create a project
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
@@ -244,7 +249,7 @@ def setUpServiceProducts(cls):
'uom_po_id': uom_hour.id,
'default_code': 'SERV-DELI3',
'service_type': 'manual',
'service_tracking': 'task_new_project',
'service_tracking': 'task_in_project',
'project_id': False, # will create a project
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
@@ -21,7 +21,7 @@ def setUpClass(cls):
service_values = {
'type': 'service',
'service_type': 'timesheet',
'service_tracking': 'task_new_project'
'service_tracking': 'task_in_project'
}
cls.product_ordered_cost.write(service_values)
cls.product_deliver_cost.write(service_values)
@@ -390,6 +390,106 @@ def test_sale_create_project(self):
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
Oops, something went wrong.

0 comments on commit 1191579

Please sign in to comment.
You can’t perform that action at this time.