[IMP] sale_timesheet: improve 'task_in_project' to handle more use cases

Task #1915660

Purpose
=======

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 info tab, shows only when a product on the SO has the option activated
if projec its selected on SO, create task in this project

Tech Spec (jem)
=======

On product:
- rename 'task_new_project' into "task_in_project" (help message precise either new project (based on template) or
  SO's project is prior
- allow to have the new option with project template

On sale order:
- non stored computed field to check if at least one SOL has the new option.
- if so, display project field
- selectable project are billate_type in 'no' and 'task_rate' (TBC for the 2nd)
  [awa: also add 'analytic_account_id' != False in domain]
- setting project on SO should change the AA of the SO.
  [awa: add onchange on project_id to select this project's analytic account on the SO]
- Never link SO's project to SO or AA
  [awa: do not touch the project_id's sale_order_id and analytic_account_id fields for this use case]
- 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
This commit is contained in:
Aurélien Warnon
2019-03-14 14:31:04 +00:00
parent 266c6657a6
commit 016a99f745
6 changed files with 153 additions and 2 deletions
+4 -1
View File
@@ -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.')
+39 -1
View File
@@ -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:
+5
View File
@@ -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',
@@ -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
@@ -37,6 +37,10 @@
<field name="tasks_count" widget="statinfo" string="Tasks"/>
</button>
</xpath>
<xpath expr="//field[@name='analytic_account_id']" position="after">
<field name="visible_project" invisible="1"/>
<field name="project_id" options="{'no_create': True}" attrs="{'invisible': [('visible_project', '=', False)]}"/>
</xpath>
</data>
</field>
</record>
@@ -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,