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 @@ +
+
+ +
+
+
+

Time Management

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 @@
-