diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py
index 8ab95b05cab..f52ca08d8e8 100644
--- a/addons/hr_timesheet/models/project.py
+++ b/addons/hr_timesheet/models/project.py
@@ -135,22 +135,16 @@ class Task(models.Model):
for task in self:
task.subtask_effective_hours = sum(child_task.effective_hours + child_task.subtask_effective_hours for child_task in task.child_ids)
+ # ---------------------------------------------------------
+ # ORM
+ # ---------------------------------------------------------
+
@api.multi
def write(self, values):
- result = super(Task, self).write(values)
- # reassign project_id on related timesheet lines
- if 'project_id' in values:
- project_id = values.get('project_id')
- # a timesheet must have an analytic account (and a project)
- if self and not project_id:
+ # a timesheet must have an analytic account (and a project)
+ if 'project_id' in values and self and not values.get('project_id'):
raise UserError(_('This task must be part of a project because they some timesheets are linked to it.'))
- account_id = self.env['project.project'].browse(project_id).sudo().analytic_account_id
- self.sudo().mapped('timesheet_ids').write({
- 'project_id': project_id,
- 'account_id': account_id.id,
- 'company_id': account_id.company_id.id,
- })
- return result
+ return super(Task, self).write(values)
@api.model
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py
index 616af044ff7..8c29bb2ab67 100644
--- a/addons/hr_timesheet/tests/test_timesheet.py
+++ b/addons/hr_timesheet/tests/test_timesheet.py
@@ -146,7 +146,7 @@ class TestTimesheet(TestCommonTimesheet):
self.assertEquals(timesheet1.user_id, self.user_employee2, 'Changing timesheet employee should change the related user')
def test_transfert_project(self):
- """ Test transfert task with timesheet to another project """
+ """ Transfert task with timesheet to another project should not modified past timesheets (they are still linked to old project. """
Timesheet = self.env['account.analytic.line']
# create a second project
self.project_customer2 = self.env['project.project'].create({
@@ -174,9 +174,9 @@ class TestTimesheet(TestCommonTimesheet):
timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_customer.id)])
timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_customer2.id)])
- self.assertEquals(timesheet_count1, 0, "No timesheet in project 1")
- self.assertEquals(timesheet_count2, 1, "One timesheet in project 2")
- self.assertEquals(len(self.task1.timesheet_ids), 1, "The timesheet should be linked to task 1")
+ self.assertEquals(timesheet_count1, 1, "Still one timesheet in project 1")
+ self.assertEquals(timesheet_count2, 0, "No timesheet in project 2")
+ self.assertEquals(len(self.task1.timesheet_ids), 1, "The timesheet still should be linked to task 1")
# it is forbidden to set a task with timesheet without project
with self.assertRaises(UserError):
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index bddeff7d218..7b947a62344 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -410,6 +410,7 @@ class Task(models.Model):
result.update(self._subtask_values_from_parent(result['parent_id']))
return result
+ @api.model
def _get_default_partner(self):
if 'default_project_id' in self.env.context:
default_project_id = self.env['project.project'].browse(self.env.context['default_project_id'])
@@ -478,7 +479,7 @@ class Task(models.Model):
index=True, track_visibility='always')
partner_id = fields.Many2one('res.partner',
string='Customer',
- default=_get_default_partner)
+ default=lambda self: self._get_default_partner())
manager_id = fields.Many2one('res.users', string='Project Manager', related='project_id.user_id', readonly=True, related_sudo=False)
company_id = fields.Many2one('res.company',
string='Company',
diff --git a/addons/project/models/res_config_settings.py b/addons/project/models/res_config_settings.py
index c6815aaae90..ff28c566c96 100644
--- a/addons/project/models/res_config_settings.py
+++ b/addons/project/models/res_config_settings.py
@@ -8,5 +8,6 @@ class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
module_project_forecast = fields.Boolean(string="Forecasts")
+ module_hr_timesheet = fields.Boolean(string="Task Logs")
group_subtask_project = fields.Boolean("Sub-tasks", implied_group="project.group_subtask_project")
group_project_rating = fields.Boolean("Use Rating on Project", implied_group='project.group_project_rating')
diff --git a/addons/project/views/res_config_settings_views.xml b/addons/project/views/res_config_settings_views.xml
index d052efeac6f..48ba37f44c9 100644
--- a/addons/project/views/res_config_settings_views.xml
+++ b/addons/project/views/res_config_settings_views.xml
@@ -48,6 +48,17 @@
+
diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py
index 1e0cd17a038..2b82f175ac1 100644
--- a/addons/sale_timesheet/controllers/main.py
+++ b/addons/sale_timesheet/controllers/main.py
@@ -362,6 +362,13 @@ class SaleTimesheetController(http.Controller):
def _plan_get_stat_button(self, projects):
stat_buttons = []
+ if len(projects) == 1:
+ stat_buttons.append({
+ 'name': _('Project'),
+ 'res_model': 'project.project',
+ 'res_id': projects.id,
+ 'icon': 'fa fa-puzzle-piece',
+ })
stat_buttons.append({
'name': _('Timesheets'),
'res_model': 'account.analytic.line',
@@ -397,7 +404,7 @@ class SaleTimesheetController(http.Controller):
return stat_buttons
@http.route('/timesheet/plan/action', type='json', auth="user")
- def plan_stat_button(self, domain, res_model='account.analytic.line'):
+ def plan_stat_button(self, domain=[], res_model='account.analytic.line', res_id=False):
action = {
'type': 'ir.actions.act_window',
'view_id': False,
@@ -405,7 +412,18 @@ class SaleTimesheetController(http.Controller):
'view_type': 'list',
'domain': domain,
}
- if res_model == 'account.analytic.line':
+ if res_model == 'project.project':
+ view_form_id = request.env.ref('project.edit_project').id
+ action = {
+ 'name': _('Project'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': res_model,
+ 'view_mode': 'form',
+ 'view_type': 'form',
+ 'views': [[view_form_id, 'form']],
+ 'res_id': res_id,
+ }
+ elif res_model == 'account.analytic.line':
ts_view_tree_id = request.env.ref('hr_timesheet.hr_timesheet_line_tree').id
ts_view_form_id = request.env.ref('hr_timesheet.hr_timesheet_line_form').id
action = {
diff --git a/addons/sale_timesheet/models/__init__.py b/addons/sale_timesheet/models/__init__.py
index e6421022c33..60c83adf30d 100644
--- a/addons/sale_timesheet/models/__init__.py
+++ b/addons/sale_timesheet/models/__init__.py
@@ -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
diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py
index 55a7f83c339..d64457c20a6 100644
--- a/addons/sale_timesheet/models/account.py
+++ b/addons/sale_timesheet/models/account.py
@@ -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,11 +37,28 @@ 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
if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and self.filtered(lambda timesheet: timesheet.timesheet_invoice_id):
- if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'task_id', 'so_line', 'amount', 'date']]):
+ if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'project_id', 'task_id', 'so_line', 'amount', 'date']]):
raise UserError(_('You can not modify already invoiced timesheets (linked to a Sales order items invoiced on Time and material).'))
result = super(AccountAnalyticLine, self).write(values)
return result
@@ -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']
diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py
index 0aecc0bb211..1d269f1267a 100644
--- a/addons/sale_timesheet/models/product.py
+++ b/addons/sale_timesheet/models/product.py
@@ -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')
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
index 337e3f0f7f2..32aaa56806c 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -8,7 +8,51 @@ 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", 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_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):
@@ -52,36 +96,100 @@ 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"
+ @api.model
def _get_default_partner(self):
- partner = super(ProjectTask, self)._get_default_partner()
+ partner = False
if 'default_project_id' in self.env.context: # partner from SO line is prior on one from project
project = self.env['project.project'].browse(self.env.context['default_project_id'])
partner = project.sale_line_id.order_partner_id
+ if not partner:
+ partner = super(ProjectTask, self)._get_default_partner()
return partner
@api.model
def _default_sale_line_id(self):
sale_line_id = False
if self._context.get('default_parent_id'):
- sale_line_id = self.env['project.task'].browse(self._context['default_parent_id']).sale_line_id.id
+ parent_task = self.env['project.task'].browse(self._context['default_parent_id'])
+ sale_line_id = parent_task.sale_line_id.id
if not sale_line_id and self._context.get('default_project_id'):
- sale_line_id = self.env['project.project'].browse(self._context['default_project_id']).sale_line_id.id
+ project = self.env['project.project'].browse(self.env.context['default_project_id'])
+ if project.billable_type != 'no':
+ 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", 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')
+
+ @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')
+ def _onchange_partner_id(self):
+ result = super(ProjectTask, self)._onchange_partner_id()
+ if self.sale_line_id.order_partner_id != self.partner_id:
+ self.sale_line_id = False
return result
@api.multi
@@ -94,13 +202,11 @@ class ProjectTask(models.Model):
@api.multi
def write(self, values):
- result = super(ProjectTask, self).write(values)
- # reassign SO line on related timesheet lines
- if 'sale_line_id' in values:
- self.sudo().mapped('timesheet_ids').write({
- 'so_line': values['sale_line_id']
- })
- return result
+ if values.get('project_id'):
+ 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
def unlink(self):
@@ -108,6 +214,28 @@ class ProjectTask(models.Model):
raise ValidationError(_('You have to unlink the task from the sale order item in order to delete it.'))
return super(ProjectTask, self).unlink()
+ # ---------------------------------------------------
+ # Subtasks
+ # ---------------------------------------------------
+
+ @api.model
+ def _subtask_implied_fields(self):
+ result = super(ProjectTask, self)._subtask_implied_fields()
+ return result + ['sale_line_id']
+
+ def _subtask_write_values(self, values):
+ result = super(ProjectTask, self)._subtask_write_values(values)
+ # changing the partner on a task will reset the sale line of its subtasks
+ if 'partner_id' in result:
+ result['sale_line_id'] = False
+ elif 'sale_line_id' in result:
+ result.pop('sale_line_id')
+ return result
+
+ # ---------------------------------------------------
+ # Actions
+ # ---------------------------------------------------
+
@api.multi
def action_view_so(self):
self.ensure_one()
@@ -115,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},
}
diff --git a/addons/sale_timesheet/models/project_sale_line_employee_map.py b/addons/sale_timesheet/models/project_sale_line_employee_map.py
new file mode 100644
index 00000000000..78174b654d7
--- /dev/null
+++ b/addons/sale_timesheet/models/project_sale_line_employee_map.py
@@ -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.'),
+ ]
diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py
index ce34b6c1a82..de856098858 100644
--- a/addons/sale_timesheet/models/sale_order.py
+++ b/addons/sale_timesheet/models/sale_order.py
@@ -222,6 +222,7 @@ class SaleOrderLine(models.Model):
'analytic_account_id': account.id,
'partner_id': self.order_id.partner_id.id,
'sale_line_id': self.id,
+ '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)
diff --git a/addons/sale_timesheet/security/ir.model.access.csv b/addons/sale_timesheet/security/ir.model.access.csv
index bce0148e1f3..4f021e6ade1 100644
--- a/addons/sale_timesheet/security/ir.model.access.csv
+++ b/addons/sale_timesheet/security/ir.model.access.csv
@@ -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
diff --git a/addons/sale_timesheet/static/src/js/timesheet_plan.js b/addons/sale_timesheet/static/src/js/timesheet_plan.js
index afebfac23fe..90cbcd98379 100644
--- a/addons/sale_timesheet/static/src/js/timesheet_plan.js
+++ b/addons/sale_timesheet/static/src/js/timesheet_plan.js
@@ -232,7 +232,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
views: [[false, 'list'], [false, 'form']],
view_type: 'list',
view_mode: 'form',
- domain: domain,
+ domain: domain || [],
});
},
/**
@@ -242,12 +242,16 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
_onClickStatButton: function (event) {
var self = this;
var data = $(event.currentTarget).data();
+ var parameters = {
+ domain: data.domain || [],
+ res_model: data.resModel,
+ }
+ if (data.resId) {
+ parameters['res_id'] = data.resId;
+ }
return this._rpc({
route:"/timesheet/plan/action",
- params: {
- domain: data.domain,
- res_model: data.resModel,
- },
+ params: parameters,
}).then(function(action){
self.do_action(action);
});
diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py
index ea0be4e193f..689ea8ed4eb 100644
--- a/addons/sale_timesheet/tests/__init__.py
+++ b/addons/sale_timesheet/tests/__init__.py
@@ -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
diff --git a/addons/sale_timesheet/tests/test_project_billing.py b/addons/sale_timesheet/tests/test_project_billing.py
new file mode 100644
index 00000000000..35403fa6ac5
--- /dev/null
+++ b/addons/sale_timesheet/tests/test_project_billing.py
@@ -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")
diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py
index f4a1a0dc263..0a91161132c 100644
--- a/addons/sale_timesheet/tests/test_sale_service.py
+++ b/addons/sale_timesheet/tests/test_sale_service.py
@@ -160,11 +160,8 @@ class TestSaleService(TestCommonSaleTimesheetNoChart):
# make task non billable
task_serv2.write({'sale_line_id': False})
- self.assertTrue(all([billing_type == 'non_billable' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "Timesheet to a non billable task should be non billable too")
-
- # make task billable again
- task_serv2.write({'sale_line_id': so_line_deliver_global_project.id})
- self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "Timesheet to a billable time task should be billable")
+ self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "billable type of timesheet should not change when tranfering task into another project")
+ self.assertEqual(task_serv2.timesheet_ids.mapped('so_line'), so_line_deliver_global_project, "Old timesheet are not modified when changing the task SO line")
# invoice SO, and validate invoice
invoice_id = self.sale_order.action_invoice_create()[0]
@@ -173,7 +170,7 @@ class TestSaleService(TestCommonSaleTimesheetNoChart):
# try to update timesheets, catch error 'You cannot modify invoiced timesheet'
with self.assertRaises(UserError):
- task_serv2.write({'sale_line_id': False})
+ timesheets.write({'so_line': False})
def test_delivered_quantity(self):
# create SO line and confirm it
diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml
index 782429c2e33..501fd515449 100644
--- a/addons/sale_timesheet/views/hr_timesheet_templates.xml
+++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml
@@ -25,9 +25,9 @@