[IMP] sale_timesheet: billing time at employee rate

When a project become billable, the employee can now timesheet
on a same task, at different rate.
The project should be linked to a SO, and the mapping between
the employee and the SO line will be done using 2 mecanisms:

1/ task rate: the SO line set on the task is chosen for any employee
timesheeting on the task.

2/ employee rate: a map exists on the project, matching employee
with an SO line from the SO of the project. If the employee is not in
the map, the fallback will be the SO from the task, or the one from
the project.
This makes the SO line on the task always visible.

The billable type of a project depends on if the map is filled
or not. There is 2 ways of making a project billable:
- project is created from SO confirmation (depending of product
configuration)
- through the wizard "Create Sales Order": a SO will be created
and the project will be associated to it.

The subtask should follow the configuration of its parent
project (and not parent task).

Task #34703
Closes #26111
This commit is contained in:
jem-odoo
2018-08-13 17:32:22 +02:00
parent 3e1207c4ac
commit b4af54f9c2
12 changed files with 618 additions and 96 deletions
+1
View File
@@ -6,3 +6,4 @@ from . import account_invoice
from . import product
from . import project
from . import sale_order
from . import project_sale_line_employee_map
+54 -4
View File
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import UserError
from odoo.exceptions import UserError, ValidationError
from odoo import api, fields, models, _
from odoo.osv import expression
@@ -36,6 +37,23 @@ class AccountAnalyticLine(models.Model):
invoice_type = 'billable_fixed'
timesheet.timesheet_invoice_type = invoice_type
@api.onchange('employee_id')
def _onchange_task_id_employee_id(self):
if self.project_id: # timesheet only
if self.task_id.billable_type == 'task_rate':
self.so_line = self.task_id.sale_line_id
elif self.task_id.billable_type == 'employee_rate':
self.so_line = self._timesheet_determine_sale_line(self.task_id, self.employee_id)
else:
self.so_line = False
@api.constrains('so_line', 'project_id')
def _check_sale_line_in_project_map(self):
for timesheet in self:
if timesheet.project_id and timesheet.so_line: # billed timesheet
if timesheet.so_line not in timesheet.project_id.mapped('sale_line_employee_ids.sale_line_id') | timesheet.task_id.sale_line_id | timesheet.project_id.sale_line_id:
raise ValidationError(_("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line."))
@api.multi
def write(self, values):
# prevent to update invoiced timesheets if one line is of type delivery
@@ -48,8 +66,40 @@ class AccountAnalyticLine(models.Model):
@api.model
def _timesheet_preprocess(self, values):
values = super(AccountAnalyticLine, self)._timesheet_preprocess(values)
# task implies so line
if 'task_id' in values:
# task implies so line (at create)
if 'task_id' in values and not values.get('so_line') and values.get('employee_id'):
task = self.env['project.task'].sudo().browse(values['task_id'])
values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
employee = self.env['hr.employee'].sudo().browse(values['employee_id'])
values['so_line'] = self._timesheet_determine_sale_line(task, employee).id
return values
@api.multi
def _timesheet_postprocess_values(self, values):
result = super(AccountAnalyticLine, self)._timesheet_postprocess_values(values)
# (re)compute the sale line
if any([field_name in values for field_name in ['task_id', 'employee_id']]):
for timesheet in self:
result[timesheet.id].update({
'so_line': timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id).id,
})
return result
@api.model
def _timesheet_determine_sale_line(self, task, employee):
""" Deduce the SO line associated to the timesheet line:
1/ timesheet on task rate: the so line will be the one from the task
2/ timesheet on employee rate task: find the SO line in the map of the project (even for subtask), or fallback on the SO line of the task, or fallback
on the one on the project
NOTE: this have to be consistent with `_compute_billable_type` on project.task.
"""
if task.billable_type != 'no':
if task.billable_type == 'employee_rate':
map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', task.project_id.id), ('employee_id', '=', employee.id)])
if map_entry:
return map_entry.sale_line_id
if task.sale_line_id:
return task.sale_line_id
return task.project_id.sale_line_id
elif task.billable_type == 'task_rate':
return task.sale_line_id
return self.env['sale.order.line']
+2 -2
View File
@@ -24,10 +24,10 @@ class ProductTemplate(models.Model):
], 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.")
project_id = fields.Many2one(
'project.project', 'Project', company_dependent=True, domain=[('sale_line_id', '=', False)],
'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.')
project_template_id = fields.Many2one(
'project.project', 'Project Template', company_dependent=True, domain=[('sale_line_id', '=', False)], copy=True,
'project.project', 'Project Template', company_dependent=True, domain=[('billable_type', '=', 'no')], copy=True,
help='Select a non billable project to be the skeleton of the new created project when selling the current product. Its stages and tasks will be duplicated.')
@api.depends('invoice_policy', 'service_type')
+96 -41
View File
@@ -8,18 +8,52 @@ from odoo.exceptions import ValidationError
class Project(models.Model):
_inherit = 'project.project'
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line', domain=[('is_expense', '=', False)], readonly=True, help="Sale order line from which the project has been created. Used for tracability.")
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain="[('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done'])]", copy=False, help="Sale order line from which the project has been created. Used for tracability.")
sale_order_id = fields.Many2one('sale.order', 'Sales Order', domain="[('partner_id', '=', partner_id)]", readonly=True, copy=False)
billable_type = fields.Selection([
('task_rate', 'At Task Rate'),
('employee_rate', 'At Employee Rate'),
('no', 'No Billable')
], string="Billable Type", default='no', required=True, help='Billable type implies:\n'
], string="Billable Type", compute='_compute_billable_type', compute_sudo=True, store=True,
help='Billable type implies:\n'
' - At task rate: each time spend on a task is billed at task rate.\n'
' - At employee rate: each employee log time billed at his rate.\n'
' - No Billable: track time without invoicing it')
sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False)
_sql_constraints = [
('sale_line_required_task_rate', "CHECK((billable_type='task_rate' AND sale_line_id IS NOT NULL) OR (billable_type != 'task_rate'))", "The project should be linked to a sale order item, as it is billed at task rate."),
('sale_order_required_if_sale_line', "CHECK((sale_line_id IS NOT NULL AND sale_order_id IS NOT NULL) OR (sale_line_id IS NULL))", 'The Project should be linked to a Sale Order to select an Sale Order Items.'),
]
@api.depends('sale_order_id', 'sale_line_id', 'sale_line_employee_ids')
def _compute_billable_type(self):
for project in self:
billable_type = 'no'
if project.sale_order_id:
if project.sale_line_employee_ids:
billable_type = 'employee_rate'
else:
billable_type = 'task_rate'
project.billable_type = billable_type
@api.onchange('sale_line_employee_ids', 'billable_type')
def _onchange_sale_line_employee_ids(self):
if self.billable_type == 'task_rate':
if self.sale_line_employee_ids:
self.billable_type = 'employee_rate'
else:
if self.billable_type == 'no':
self.sale_line_employee_ids = False
@api.constrains('sale_line_id', 'billable_type')
def _check_sale_line_type(self):
for project in self:
if project.billable_type == 'task_rate':
if project.sale_line_id and not project.sale_line_id.is_service:
raise ValidationError(_("A billable project should be linked to a Sales Order Item having a Service product."))
if project.sale_line_id and project.sale_line_id.is_expense:
raise ValidationError(_("A billable project should be linked to a Sales Order Item that does not come from an expense or a vendor bill."))
@api.multi
def action_view_timesheet(self):
self.ensure_one()
@@ -62,6 +96,19 @@ class Project(models.Model):
}
return action
@api.multi
def action_make_billable(self):
return {
"type": 'ir.actions.act_window',
"res_model": 'project.create.sale.order',
"views": [[False, "form"]],
"target": 'new',
"context": {
'active_id': self.id,
'active_model': 'project.project',
},
}
class ProjectTask(models.Model):
_inherit = "project.task"
@@ -81,32 +128,61 @@ class ProjectTask(models.Model):
sale_line_id = False
if self._context.get('default_parent_id'):
parent_task = self.env['project.task'].browse(self._context['default_parent_id'])
if parent_task.project_id.billable_type != 'no':
sale_line_id = parent_task.sale_line_id.id
sale_line_id = parent_task.sale_line_id.id
if not sale_line_id and self._context.get('default_project_id'):
project = self.env['project.project'].browse(self.env.context['default_project_id'])
if project.billable_type != 'no':
return project.sale_line_id
sale_line_id = project.sale_line_id.id
return sale_line_id
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False)]")
sale_order_id = fields.Many2one('sale.order', 'Sales Order', related='sale_line_id.order_id', store=True, readonly=True)
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]")
sale_order_id = fields.Many2one('sale.order', 'Sales Order', compute='_compute_sale_order_id', compute_sudo=True, store=True, readonly=True)
billable_type = fields.Selection([
('task_rate', 'At Task Rate'),
('employee_rate', 'At Employee Rate'),
('no', 'No Billable')
], string="Billable Type", default='no', required=True, readonly=True)
], string="Billable Type", compute='_compute_billable_type', compute_sudo=True, store=True)
is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty')
_sql_constraints = [
('sale_line_required_task_rate', "CHECK((billable_type='task_rate' AND sale_line_id IS NOT NULL) OR (billable_type != 'task_rate'))", "The task should be linked to a sale order item, as it is billed at task rate."),
]
@api.multi
@api.depends('sale_line_id', 'project_id', 'billable_type')
def _compute_sale_order_id(self):
for task in self:
if task.billable_type == 'task_rate':
task.sale_order_id = task.sale_line_id.order_id or task.project_id.sale_order_id
elif task.billable_type == 'employee_rate':
task.sale_order_id = task.project_id.sale_order_id
elif task.billable_type == 'no':
task.sale_order_id = False
@api.multi
@api.depends('project_id.billable_type', 'sale_line_id')
def _compute_billable_type(self):
for task in self:
billable_type = 'no'
if task.project_id.billable_type == 'employee_rate':
billable_type = task.project_id.billable_type
elif (task.project_id.billable_type in ['task_rate', 'no'] and task.sale_line_id): # create a task in global project (non billable)
billable_type = 'task_rate'
task.billable_type = billable_type
@api.depends('project_id.sale_line_employee_ids')
def _compute_is_project_map_empty(self):
for task in self:
task.is_project_map_empty = not bool(task.project_id.sale_line_employee_ids)
@api.onchange('project_id')
def _onchange_project(self):
result = super(ProjectTask, self)._onchange_project()
if self.project_id:
self.sale_line_id = self.project_id.sale_line_id
if not self.partner_id:
self.partner_id = self.sale_line_id.order_partner_id
self.sale_line_id = self.project_id.sale_line_id
if not self.parent_id and not self.partner_id:
self.partner_id = self.sale_line_id.order_partner_id
# set domain on SO: on non billable project, all SOL of customer, otherwise the one from the SO
result = result or {}
domain = [('is_service', '=', True), ('is_expense', '=', False), ('order_partner_id', '=', self.partner_id.id), ('state', 'in', ['sale', 'done'])]
if self.project_id.sale_order_id:
domain += [('sale_order_id', '=', self.project_id.sale_order_id.id)]
result.setdefault('domain', {})['sale_line_id'] = domain
return result
@api.onchange('partner_id')
@@ -124,33 +200,12 @@ class ProjectTask(models.Model):
if not task.sale_line_id.is_service or task.sale_line_id.is_expense:
raise ValidationError(_('You cannot link the order item %s - %s to this task because it is a re-invoiced expense.' % (task.sale_line_id.order_id.id, task.sale_line_id.product_id.name)))
@api.multi
@api.constrains('billable_type', 'sale_line_id')
def _check_billable_type(self):
for task in self:
if task.sale_line_id and task.billable_type == 'no':
raise ValidationError(_("A billable task should be linked to a sale order item."))
@api.model
def create(self, values):
# sub task has the same so line than their parent
parent_id = values['parent_id'] if 'parent_id' in values else self.env.context.get('default_parent_id')
if parent_id:
values['sale_line_id'] = self.env['project.task'].browse(parent_id).sudo().sale_line_id.id
# determine billable type from the project
project_id = values['project_id'] if values.get('project_id') else self.env.context.get('default_project_id')
if not values.get('billable_type') and project_id:
values['billable_type'] = self.env['project.project'].browse(project_id).billable_type
return super(ProjectTask, self).create(values)
@api.multi
def write(self, values):
# changing the project forces the billable type
if values.get('project_id'):
values['billable_type'] = self.env['project.project'].browse(values['project_id']).billable_type
else:
values['billable_type'] = 'no'
project_dest = self.env['project.project'].browse(values['project_id'])
if project_dest.billable_type == 'employee_rate':
values['sale_line_id'] = False
return super(ProjectTask, self).write(values)
@api.multi
@@ -188,7 +243,7 @@ class ProjectTask(models.Model):
"type": "ir.actions.act_window",
"res_model": "sale.order",
"views": [[False, "form"]],
"res_id": self.sale_line_id.order_id.id,
"res_id": self.sale_order_id.id,
"context": {"create": False, "show_sale": True},
}
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ProjectProductEmployeeMap(models.Model):
_name = 'project.sale.line.employee.map'
@api.model
def _default_project_id(self):
if self._context.get('active_id'):
return self._context['active_id']
return False
project_id = fields.Many2one('project.project', "Project", domain=[('billable_type', '!=', 'no')], required=True, default=_default_project_id)
employee_id = fields.Many2one('hr.employee', "Employee", required=True)
sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", domain=[('is_service', '=', True)], required=True)
price_unit = fields.Float(related='sale_line_id.price_unit', readonly=True)
_sql_constraints = [
('uniq_map_sale_line_employee_per_project', 'UNIQUE(project_id,employee_id)', 'You can only map one employee with sale order item per project.'),
]
+1 -2
View File
@@ -222,7 +222,7 @@ class SaleOrderLine(models.Model):
'analytic_account_id': account.id,
'partner_id': self.order_id.partner_id.id,
'sale_line_id': self.id,
'billable_type': 'task_rate',
'sale_order_id': self.order_id.id,
}
if self.product_id.project_template_id:
values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name)
@@ -254,7 +254,6 @@ class SaleOrderLine(models.Model):
'sale_line_id': self.id,
'company_id': self.company_id.id,
'user_id': False, # force non assigned task, as created as sudo()
'billable_type': 'task_rate',
}
@api.multi
@@ -1,2 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_project_profitability_report_analysis_manager,project.profitability.report.analysis,model_project_profitability_report,project.group_project_manager,1,1,1,1
access_project_sale_line_employee_map,access_project_sale_line_employee_map,model_project_sale_line_employee_map,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_project_profitability_report_analysis_manager project.profitability.report.analysis model_project_profitability_report project.group_project_manager 1 1 1 1
3 access_project_sale_line_employee_map access_project_sale_line_employee_map model_project_sale_line_employee_map base.group_user 1 0 0 0
+1
View File
@@ -4,5 +4,6 @@
from . import common
from . import test_sale_timesheet
from . import test_sale_service
from . import test_project_billing
from . import test_reinvoice
from . import test_reporting
@@ -0,0 +1,330 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheetNoChart
from odoo.exceptions import UserError
class TestProjectBilling(TestCommonSaleTimesheetNoChart):
""" This test suite provide checks for miscellaneous small things. """
@classmethod
def setUpClass(cls):
super(TestProjectBilling, cls).setUpClass()
# set up
cls.setUpServiceProducts()
cls.setUpEmployees()
cls.employee_tde = cls.env['hr.employee'].create({
'name': 'Employee TDE',
'timesheet_cost': 42,
})
cls.partner_2 = cls.env['res.partner'].create({
'name': 'Customer from the South',
'email': 'customer.usd@south.com',
'customer': True,
'property_account_payable_id': cls.account_payable.id,
'property_account_receivable_id': cls.account_receivable.id,
})
# Sale Order 1, no project/task created, used to timesheet at employee rate
SaleOrder = cls.env['sale.order'].with_context(tracking_disable=True)
SaleOrderLine = cls.env['sale.order.line'].with_context(tracking_disable=True)
cls.sale_order_1 = SaleOrder.create({
'partner_id': cls.partner_customer_usd.id,
'partner_invoice_id': cls.partner_customer_usd.id,
'partner_shipping_id': cls.partner_customer_usd.id,
})
cls.so1_line_order_no_task = SaleOrderLine.create({
'name': cls.product_order_timesheet1.name,
'product_id': cls.product_order_timesheet1.id,
'product_uom_qty': 10,
'product_uom': cls.product_order_timesheet1.uom_id.id,
'price_unit': cls.product_order_timesheet1.list_price,
'order_id': cls.sale_order_1.id,
})
cls.so1_line_deliver_no_task = SaleOrderLine.create({
'name': cls.product_delivery_timesheet1.name,
'product_id': cls.product_delivery_timesheet1.id,
'product_uom_qty': 10,
'product_uom': cls.product_delivery_timesheet1.uom_id.id,
'price_unit': cls.product_delivery_timesheet1.list_price,
'order_id': cls.sale_order_1.id,
})
# Sale Order 2, creates 2 project billed at task rate
cls.sale_order_2 = SaleOrder.create({
'partner_id': cls.partner_2.id,
'partner_invoice_id': cls.partner_2.id,
'partner_shipping_id': cls.partner_2.id,
})
cls.so2_line_deliver_project_task = SaleOrderLine.create({
'order_id': cls.sale_order_2.id,
'name': cls.product_delivery_timesheet3.name,
'product_id': cls.product_delivery_timesheet3.id,
'product_uom_qty': 5,
'product_uom': cls.product_delivery_timesheet3.uom_id.id,
'price_unit': cls.product_delivery_timesheet3.list_price
})
cls.so2_line_deliver_project_template = SaleOrderLine.create({
'order_id': cls.sale_order_2.id,
'name': cls.product_delivery_timesheet5.name,
'product_id': cls.product_delivery_timesheet5.id,
'product_uom_qty': 7,
'product_uom': cls.product_delivery_timesheet5.uom_id.id,
'price_unit': cls.product_delivery_timesheet5.list_price
})
cls.sale_order_2.action_confirm()
# Projects: at least one per billable type
Project = cls.env['project.project'].with_context(tracking_disable=True)
cls.project_subtask = Project.create({
'name': "Sub Task Project (non billable)",
'allow_timesheets': True,
'billable_type': 'no',
'partner_id': False,
})
cls.project_non_billable = Project.create({
'name': "Non Billable Project",
'allow_timesheets': True,
'billable_type': 'no',
'partner_id': False,
'subtask_project_id': cls.project_subtask.id,
})
cls.project_task_rate = cls.env['project.project'].search([('sale_line_id', '=', cls.so2_line_deliver_project_task.id)], limit=1)
cls.project_task_rate2 = cls.env['project.project'].search([('sale_line_id', '=', cls.so2_line_deliver_project_template.id)], limit=1)
cls.project_employee_rate = Project.create({
'name': "Project billed at Employee Rate",
'allow_timesheets': True,
'billable_type': 'employee_rate',
'sale_order_id': cls.sale_order_1.id,
'partner_id': cls.sale_order_1.partner_id.id,
'subtask_project_id': cls.project_subtask.id,
})
cls.project_employee_rate_manager = cls.env['project.sale.line.employee.map'].create({
'project_id': cls.project_employee_rate.id,
'sale_line_id': cls.so1_line_order_no_task.id,
'employee_id': cls.employee_manager.id,
})
cls.project_employee_rate_user = cls.env['project.sale.line.employee.map'].create({
'project_id': cls.project_employee_rate.id,
'sale_line_id': cls.so1_line_deliver_no_task.id,
'employee_id': cls.employee_user.id,
})
def test_make_billable_at_task_rate(self):
""" Starting from a non billable project, make it billable at task rate, using the wizard """
# set a customer on the project
self.project_non_billable.write({
'partner_id': self.partner_2.id
})
# create wizard
wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({
'line_ids': [
(0, 0, {'product_id': self.product_delivery_timesheet3.id, 'quantity': 10, 'price_unit': self.product_delivery_timesheet3.list_price}) # product creates new T in new P
]
})
self.assertEqual(self.project_non_billable.billable_type, 'no', "The project should still be non billable")
self.assertEqual(wizard.partner_id, self.project_non_billable.partner_id, "The wizard should have the same partner as the project")
# create the SO from the project
action = wizard.action_create_sale_order()
sale_order = self.env['sale.order'].browse(action['res_id'])
self.assertEqual(self.project_non_billable.billable_type, 'task_rate', "The project should be 'task rate' billable")
self.assertEqual(sale_order.partner_id, self.project_non_billable.partner_id, "The customer of the SO should be the same as the project")
self.assertEqual(len(sale_order.order_line), 1, "The SO should have 1 line")
self.assertEqual(sale_order.order_line.product_id, wizard.line_ids.product_id, "The product of the only SOL should be the selected on the wizard")
self.assertEqual(sale_order.order_line.project_id, self.project_non_billable, "SOL should be linked to the project")
self.assertTrue(sale_order.order_line.task_id, "The SOL creates a task as they were no task already present in the project (system limitation)")
self.assertEqual(sale_order.order_line.task_id.project_id, self.project_non_billable, "The created task should be in the project")
def test_make_billable_at_employee_rate(self):
""" Starting from a non billable project, make it billable at employee rate, using the wizard """
# set a customer on the project
self.project_non_billable.write({
'partner_id': self.partner_2.id
})
# create wizard
wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({
'line_ids': [
(0, 0, {'product_id': self.product_delivery_timesheet1.id, 'quantity': 4, 'price_unit': self.product_delivery_timesheet1.list_price}), # product creates no T
(0, 0, {'product_id': self.product_delivery_timesheet2.id, 'quantity': 5, 'price_unit': self.product_delivery_timesheet2.list_price}), # product creates T in global project
(0, 0, {'product_id': self.product_delivery_timesheet3.id, 'quantity': 10, 'price_unit': self.product_delivery_timesheet3.list_price}), # product creates new T in new P
]
})
self.assertEqual(self.project_non_billable.billable_type, 'no', "The project should still be non billable")
self.assertEqual(wizard.partner_id, self.project_non_billable.partner_id, "The wizard should have the same partner as the project")
self.assertEqual(wizard.project_id, self.project_non_billable, "The wizard'project should be the non billable project")
# create the SO from the project
action = wizard.action_create_sale_order()
sale_order = self.env['sale.order'].browse(action['res_id'])
self.assertEqual(self.project_non_billable.billable_type, 'task_rate', "The project should be 'employee rate' billable")
self.assertEqual(sale_order.partner_id, self.project_non_billable.partner_id, "The customer of the SO should be the same as the project")
self.assertEqual(len(sale_order.order_line), 3, "The SO should have 3 lines")
line1 = sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_delivery_timesheet1)
line2 = sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_delivery_timesheet2)
line3 = sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_delivery_timesheet3)
self.assertTrue(line1, "Sale line with product 1 should exists")
self.assertTrue(line2, "Sale line with product 2 should exists")
self.assertTrue(line3, "Sale line with product 3 should exists")
self.assertFalse(line1.project_id, "Sale line 1 should be linked to the 'non billable' project")
self.assertFalse(line2.project_id, "Sale line 2 should not be linked to a project, as it creates a task in a global one")
self.assertEqual(line3.project_id, self.project_non_billable, "Sale line 3 should be linked to the 'non billable' project")
# filling the map will set billable type to emlpoyee rate
self.env['project.sale.line.employee.map'].create({
'project_id': self.project_non_billable.id,
'sale_line_id': line1.id,
'employee_id': self.employee_manager.id,
})
self.assertEqual(self.project_non_billable.billable_type, 'employee_rate', "The project should be 'employee rate' billable")
def test_billing_employee_rate(self):
""" Check task and subtask creation, and timesheeting in a project billed at 'employee rate'. Then move the task into a 'task rate' project. """
Task = self.env['project.task'].with_context(tracking_disable=True)
Timesheet = self.env['account.analytic.line']
# create a task
task = Task.with_context(default_project_id=self.project_employee_rate.id).create({
'name': 'first task',
'partner_id': self.partner_customer_usd.id,
})
self.assertEqual(task.billable_type, 'employee_rate', "Task in project 'employee rate' should be billed at employee rate")
self.assertFalse(task.sale_line_id, "Task created in a project billed on 'employee rate' should not be linked to a SOL")
self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project")
# log timesheet on task
timesheet1 = Timesheet.create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 50,
'employee_id': self.employee_manager.id,
})
self.assertEqual(self.project_employee_rate_manager.sale_line_id, timesheet1.so_line, "The timesheet should be linked to the SOL associated to the Employee manager in the map")
self.assertEqual(self.project_employee_rate_manager.project_id, timesheet1.project_id, "The timesheet should be linked to the project of the map entry")
# create a subtask
subtask = Task.with_context(default_project_id=self.project_employee_rate.subtask_project_id.id).create({
'name': 'first subtask task',
'parent_id': task.id,
})
self.assertEqual(subtask.billable_type, 'no', "Subtask in non billable project should be non billable too")
self.assertEqual(subtask.project_id.billable_type, 'no', "The subtask project is non billable even if the subtask is")
self.assertEqual(subtask.partner_id, subtask.parent_id.partner_id, "Subtask should have the same customer as the one from their mother")
# log timesheet on subtask
timesheet2 = Timesheet.create({
'name': 'Test Line on subtask',
'project_id': subtask.project_id.id,
'task_id': subtask.id,
'unit_amount': 50,
'employee_id': self.employee_user.id,
})
self.assertEqual(subtask.project_id, timesheet2.project_id, "The timesheet is in the subtask project")
self.assertNotEqual(self.project_employee_rate_user.project_id, timesheet2.project_id, "The timesheet should not be linked to the billing project for the map")
self.assertFalse(timesheet2.so_line, "The timesheet should not be linked to SOL as the task is in a non billable project")
# move task into task rate project
task.write({
'project_id': self.project_task_rate.id,
})
task._onchange_project()
self.assertEqual(task.billable_type, 'task_rate', "Task in project 'task rate' should be billed at task rate")
self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Task moved in a task rate billable project")
self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project")
# move subtask into task rate project
subtask.write({
'project_id': self.project_task_rate2.id,
})
self.assertEqual(task.billable_type, 'task_rate', "Subtask should keep the billable type from its parent, even when they are moved into another project")
self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Subtask should keep the same sale order line than their mother, even when they are moved into another project")
def test_billing_task_rate(self):
""" Check task and subtask creation, and timesheeting in a project billed at 'task rate'. Then move the task into a 'employee rate' project then, 'non billable'. """
Task = self.env['project.task'].with_context(tracking_disable=True)
Timesheet = self.env['account.analytic.line']
# set subtask project on task rate project
self.project_task_rate.write({'subtask_project_id': self.project_subtask.id})
# create a task
task = Task.with_context(default_project_id=self.project_task_rate.id).create({
'name': 'first task',
'partner_id': self.partner_customer_usd.id,
})
task._onchange_project()
self.assertEqual(task.billable_type, 'task_rate', "Task in project 'task rate' should be billed at task rate")
self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Task created in a project billed on 'task rate' should be linked to a SOL of the project")
self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project")
# log timesheet on task
timesheet1 = Timesheet.create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 50,
'employee_id': self.employee_manager.id,
})
self.assertEqual(self.project_task_rate.sale_line_id, timesheet1.so_line, "The timesheet should be linked to the SOL associated to the Employee manager in the map")
# create a subtask
subtask = Task.with_context(default_project_id=self.project_task_rate.subtask_project_id.id).create({
'name': 'first subtask task',
'parent_id': task.id,
})
self.assertEqual(subtask.billable_type, 'task_rate', "Subtask in a non billable project with a so line set is task rate billable")
self.assertEqual(subtask.project_id.billable_type, 'no', "The subtask project is non billable even if the subtask is")
self.assertEqual(subtask.partner_id, subtask.parent_id.partner_id, "Subtask should have the same customer as the one from their mother")
# log timesheet on subtask
timesheet2 = Timesheet.create({
'name': 'Test Line on subtask',
'project_id': subtask.project_id.id,
'task_id': subtask.id,
'unit_amount': 50,
'employee_id': self.employee_user.id,
})
self.assertEqual(subtask.project_id, timesheet2.project_id, "The timesheet is in the subtask project")
self.assertEqual(timesheet2.so_line, subtask.sale_line_id, "The timesheet should be linked to SOL as the task even in a non billable project")
# move task and subtask into task rate project
task.write({
'project_id': self.project_employee_rate.id,
})
task._onchange_project()
subtask.write({
'project_id': self.project_employee_rate.id,
})
subtask._onchange_project()
self.assertEqual(task.billable_type, 'employee_rate', "Task moved in project 'employee rate' should be billed at employee rate")
self.assertFalse(task.sale_line_id, "Task moved in a employee rate billable project have empty so line")
self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project")
self.assertEqual(subtask.billable_type, 'employee_rate', "subtask moved in project 'employee rate' should be billed at employee rate")
self.assertFalse(subtask.sale_line_id, "Subask moved in a employee rate billable project have empty so line")
self.assertEqual(subtask.partner_id, task.project_id.partner_id, "Subask created in a project billed on 'employee rate' should have the same customer as the one from the project")
@@ -1,6 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_project_view_form" model="ir.ui.view">
<field name="name">project.project.form.inherit</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project.edit_project"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_make_billable" string="Create Sales Order" type="object" attrs="{'invisible': [('billable_type', '!=', 'no')]}"/>
</xpath>
<xpath expr="//page[@name='emails']" position="after">
<page name="billing_employee_rate" string="Invoicing" attrs="{'invisible': [('billable_type', '=', 'no')]}">
<group>
<field name="billable_type" invisible="1"/>
<field name="sale_order_id" attrs="{'invisible': [('billable_type', '=', 'no')]}"/>
<field name="sale_line_id" attrs="{'invisible': [('billable_type', '=', 'no')]}"/>
</group>
<field name="sale_line_employee_ids">
<tree editable="top">
<field name="employee_id" options="{'no_create': True}"/>
<field name="sale_line_id" options="{'no_create': True}" domain="[('order_id','=',parent.sale_order_id), ('is_service', '=', True)]"/>
<field name="price_unit"/>
</tree>
</field>
</page>
</xpath>
</field>
</record>
<record id="project_project_view_kanban_inherit_sale_timesheet" model="ir.ui.view">
<field name="name">project.project.kanban.inherit.sale.timesheet</field>
<field name="model">project.project</field>
@@ -37,11 +64,13 @@
<xpath expr="//button[@name='toggle_active']" position="before">
<button type="object" name="action_view_so"
class="oe_stat_button" icon="fa-dollar"
attrs="{'invisible': [('sale_line_id', '=', False)]}"
attrs="{'invisible': [('sale_order_id', '=', False)]}"
string="Sales Order"/>
<field name="sale_order_id" invisible="1"/>
</xpath>
<field name="user_id" position="after">
<field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': [('partner_id', '=', False)]}" options='{"no_open": True, "no_create": True}'/>
<field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': ['|', ('partner_id', '=', False)]}" options='{"no_open": True, "no_create": True}'/>
<field name="billable_type" invisible="1"/>
</field>
</field>
</record>
@@ -66,8 +95,12 @@
<xpath expr="//field[@name='timesheet_ids']/tree" position="attributes">
<attribute name="decoration-muted">timesheet_invoice_id != False</attribute>
</xpath>
<xpath expr="//field[@name='user_id']" position="after">
<field name="is_project_map_empty" invisible="1"/>
</xpath>
<xpath expr="//field[@name='timesheet_ids']/tree" position="inside">
<field name="timesheet_invoice_id" invisible="1"/>
<field name="so_line" readonly="1" attrs="{'column_invisible': ['&amp;', ('parent.is_project_map_empty', '=', True), ('parent.sale_line_id', '!=', False)]}"/>
</xpath>
</field>
</record>
@@ -3,6 +3,7 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.addons import decimal_precision as dp
class ProjectCreateSalesOrder(models.TransientModel):
@@ -15,31 +16,30 @@ class ProjectCreateSalesOrder(models.TransientModel):
active_model = self._context.get('active_model')
if active_model != 'project.project':
raise UserError(_('You can only apply this action from a project.'))
raise UserError(_("You can only apply this action from a project."))
active_id = self._context.get('active_id')
if 'project_id' in fields and active_id:
project = self.env['project.project'].browse(active_id)
if project.billable_type != 'no':
raise UserError(_("The project is already billable."))
result['project_id'] = active_id
result['partner_id'] = self.env['project.project'].browse(active_id).partner_id.id
result['partner_id'] = project.partner_id.id
return result
project_id = fields.Many2one('project.project', "Project", domain=[('sale_line_id', '=', False)], help="Project to make billable", required=True)
product_id = fields.Many2one('product.product', domain=[('type', '=', 'service')], string="Service", required=True)
partner_id = fields.Many2one('res.partner', string="Customer", domain=[('customer', '=', True)], required=True)
price_unit = fields.Float("Price", help="Price unit of the selected product for the generated Sales Order.")
currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id')
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.price_unit = self.product_id.lst_price
line_ids = fields.One2many('project.create.sale.order.line', 'wizard_id', string='Lines')
@api.multi
def action_create_sale_order(self):
# if project linked to SO line or at least on tasks with SO line, then we consider project as billable.
if self.project_id.sale_line_id or self.project_id.tasks.mapped('sale_line_id'):
if self.project_id.sale_line_id:
raise UserError(_("The project is already linked to a sales order item."))
if not self.line_ids:
raise UserError(_("At least one Sale Order Items should be filled."))
# create SO
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_id.id,
@@ -49,37 +49,46 @@ class ProjectCreateSalesOrder(models.TransientModel):
sale_order.onchange_partner_id()
sale_order.onchange_partner_shipping_id()
planned_hours = sum(self.project_id.tasks.mapped('planned_hours'))
# trying to simulate the SO line created a task, according to the product configuration
# To avoid, generating a task when confirming the SO
task_id = False
if self.product_id.service_tracking in ['task_new_project', 'task_global_project']:
task_id = self.env['project.task'].search([('project_id', '=', self.project_id.id)], order='create_date DESC', limit=1).id
task_id = self.env['project.task'].search([('project_id', '=', self.project_id.id)], order='create_date DESC', limit=1).id
project_id = self.project_id.id
# create SO line
sale_order_line = self.env['sale.order.line'].create({
'order_id': sale_order.id,
'product_id': self.product_id.id,
'price_unit': self.price_unit,
'project_id': self.project_id.id, # prevent to re-create a project on confirmation
'task_id': task_id,
'product_uom_qty': sale_order.company_id.project_time_mode_id._compute_quantity(planned_hours, self.product_id.uom_id, raise_if_failure=False),
})
# create SO lines
for wizard_line in self.line_ids:
values = {
'order_id': sale_order.id,
'product_id': wizard_line.product_id.id,
'price_unit': wizard_line.price_unit,
'product_uom_qty': wizard_line.quantity,
}
if wizard_line.product_id.service_tracking in ['task_new_project', 'task_global_project']:
values['task_id'] = task_id
if wizard_line.product_id.service_tracking in ['task_new_project', 'project_only']:
values['project_id'] = project_id
sale_order_line = self.env['sale.order.line'].create(values)
# confirm SO
sale_order.action_confirm()
# link project and tasks to SO line
self.project_id.write({
'sale_line_id': sale_order_line.id,
'billable_type': 'task_rate',
})
self.project_id.tasks.filtered(lambda t: not t.sale_line_id).write({
'sale_line_id': sale_order_line.id,
'partner_id': sale_order.partner_id.id,
'email_from': sale_order.partner_id.email,
})
# link project and tasks to SO line according to billable type
if len(self.line_ids) == 1:
self.project_id.write({
'sale_order_id': sale_order.id,
'sale_line_id': sale_order_line.id,
'partner_id': self.partner_id.id,
})
self.project_id.tasks.filtered(lambda task: task.billable_type == 'no').write({
'sale_line_id': sale_order_line.id,
'partner_id': sale_order.partner_id.id,
'email_from': sale_order.partner_id.email,
})
else:
self.project_id.write({
'sale_order_id': sale_order.id,
'partner_id': self.partner_id.id,
})
view_form_id = self.env.ref('sale.view_order_form').id
action = self.env.ref('sale.action_orders').read()[0]
@@ -90,3 +99,19 @@ class ProjectCreateSalesOrder(models.TransientModel):
'res_id': sale_order.id,
})
return action
class ProjectCreateSalesOrderLine(models.TransientModel):
_name = 'project.create.sale.order.line'
_order = 'id,create_date'
wizard_id = fields.Many2one('project.create.sale.order', required=True)
product_id = fields.Many2one('product.product', domain=[('type', '=', 'service')], string="Service", required=True)
price_unit = fields.Float("Price", default=1.0)
quantity = fields.Float("Quantity", digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0)
currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id')
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.price_unit = self.product_id.lst_price
@@ -5,18 +5,23 @@
<field name="name">project.create.sale.order.wizard.form</field>
<field name="model">project.create.sale.order</field>
<field name="arch" type="xml">
<form string="Create Sales Order from Project">
<form string="Make Project Billable">
<group>
<group>
<field name="project_id" readonly="1"/>
<field name="product_id" options="{'no_create_edit': True, 'no_create': True}"/>
<field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
<field name="partner_id"/>
<field name="currency_id" invisible="1"/>
</group>
<field name="project_id" readonly="1"/>
<field name="partner_id"/>
</group>
<group>
<field name="line_ids" nolabel="1">
<tree editable="bottom">
<field name="product_id" options="{'no_create_edit': True, 'no_create': True}"/>
<field name="quantity"/>
<field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
<field name="currency_id" invisible="1"/>
</tree>
</field>
</group>
<footer>
<button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight"/>
<button string="Create Order" type="object" name="action_create_sale_order" class="oe_highlight"/>
<button string="Cancel" special="cancel" type="object" class="btn btn-secondary oe_inline"/>
</footer>
</form>
@@ -32,7 +37,6 @@
<field name="view_id" ref="project_create_sale_order_view_form"/>
<field name="target">new</field>
<field name="multi" eval="False"/>
<field name="binding_model_id" ref="project.model_project_project"/>
<field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>