From 20cc64f79ea00cf82242fb0cc6c28c6979928ef3 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Tue, 10 Jul 2018 14:40:32 +0200 Subject: [PATCH 1/8] [IMP] *_timesheet: don't modify past timesheet when transfering task Impacted modules: hr_timesheet, sale_timesheet When task is changing project or sale order line, we don't want anymore to change the linked timesheet. We now consider a timesheet is done in a specific time at certain condition that does not have to change when the environment is modified afterwards. So, a timesheet linked to an analytic account for a certain project on a task (and mybe linked to a SO line) will still be linked to the same object if the task is billed with another SO or move into a new project. The idea is task actual configuration (project, AA, SO line) are the default value at timesheet creation. As consequences, timesheet from another project can be linked to a task that does not belong to that project. Project and task can be modified thougth the UI of timesheet app. SO line and analytic account of a timesheet can not be modified as they are more sensitive field (in a business point of view), especially for invoiced timesheets. This commit revert opw-1803811, as at that time the 'inheritS' between project and AA was still effective, and there was only one billable type (task rate) for task. The introduction of 'employee rate' billing type will complicate timesheet transfert. Tasks #34703 --- addons/hr_timesheet/models/project.py | 20 +++++++------------ addons/hr_timesheet/tests/test_timesheet.py | 8 ++++---- addons/sale_timesheet/models/project.py | 10 ---------- .../sale_timesheet/tests/test_sale_service.py | 9 +++------ 4 files changed, 14 insertions(+), 33 deletions(-) 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/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 337e3f0f7f2..3f80dddbe45 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -92,16 +92,6 @@ 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 - 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 - @api.multi def unlink(self): if any(task.sale_line_id for task in self): 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 From 12430b59f3269e27d4cfb511967bf45e20a5ef68 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Thu, 26 Jul 2018 10:01:35 +0200 Subject: [PATCH 2/8] [FIX] project,sale_timesheet: default partner on billed task --- addons/project/models/project.py | 3 ++- addons/sale_timesheet/models/project.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) 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/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 3f80dddbe45..fdff5f5dd08 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -56,11 +56,14 @@ class Project(models.Model): 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 From ba0b3dab71c48f0dae384490f6b289c92507d629 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Tue, 31 Jul 2018 10:51:39 +0200 Subject: [PATCH 3/8] [IMP] sale_timesheet: avoid modifying project of invoiced timesheet Task #34703 --- addons/sale_timesheet/models/account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 55a7f83c339..a5d9e39d5b6 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -40,7 +40,7 @@ class AccountAnalyticLine(models.Model): 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 From cfbd57a96efec6c526042f5b8336b9baa9abb605 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Tue, 27 Feb 2018 13:57:32 +0100 Subject: [PATCH 4/8] [IMP] sale_timesheet: introduce billable type for project For futher developpement, the notion of billable type of a project and a task will appears. This commit introduces this notion to make clear the case 'task rate' was already covered. Task #34703 --- addons/sale_timesheet/models/project.py | 55 ++++++++++++++++++- addons/sale_timesheet/models/sale_order.py | 2 + .../wizard/project_create_sale_order.py | 1 + 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index fdff5f5dd08..b8a1f0fc50f 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -9,6 +9,16 @@ 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.") + billable_type = fields.Selection([ + ('task_rate', 'At Task Rate'), + ('no', 'No Billable') + ], string="Billable Type", default='no', required=True, help='Billable type implies:\n' + ' - At task rate: each time spend on a task is billed at task rate.\n' + ' - No Billable: track time without invoicing it') + + _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."), + ] @api.multi def action_view_timesheet(self): @@ -70,13 +80,25 @@ class ProjectTask(models.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']) + if parent_task.project_id.billable_type != 'no': + 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': + return project.sale_line_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) + billable_type = fields.Selection([ + ('task_rate', 'At Task Rate'), + ('no', 'No Billable') + ], string="Billable Type", default='no', required=True, readonly=True) + + _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.onchange('project_id') def _onchange_project(self): @@ -95,6 +117,35 @@ 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' + return super(ProjectTask, self).write(values) + @api.multi def unlink(self): if any(task.sale_line_id for task in self): diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index ce34b6c1a82..d2d24ef3f81 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, + 'billable_type': 'task_rate', } if self.product_id.project_template_id: values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name) @@ -253,6 +254,7 @@ 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 diff --git a/addons/sale_timesheet/wizard/project_create_sale_order.py b/addons/sale_timesheet/wizard/project_create_sale_order.py index f62129bbd55..5fa2c441dc2 100644 --- a/addons/sale_timesheet/wizard/project_create_sale_order.py +++ b/addons/sale_timesheet/wizard/project_create_sale_order.py @@ -73,6 +73,7 @@ class ProjectCreateSalesOrder(models.TransientModel): # 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, From 67c8a60affea6077e5c386b6bf93d02f30201b47 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Fri, 3 Aug 2018 16:54:47 +0200 Subject: [PATCH 5/8] [FIX] sale_timesheet: sale order line of subtasks We want to set the sale line of the parent when changing the subtasks parent but not force changing it when modifying the SO line of the parent task, and we have to reset (empty) so line on subtasks when changing the customer of a task to avoid no seeing the current SO line in its domain (dropdown) since the customer has changed. Task #34703 --- addons/sale_timesheet/models/project.py | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index b8a1f0fc50f..0d06a4d069d 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -109,6 +109,13 @@ class ProjectTask(models.Model): self.partner_id = self.sale_line_id.order_partner_id 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 @api.constrains('sale_line_id') def _check_sale_line_type(self): @@ -152,6 +159,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() From f192c3e90f63766efd0ca32211beb8f354a3eebf Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Mon, 13 Aug 2018 09:49:07 +0200 Subject: [PATCH 6/8] [IMP] sale_timesheet: add project stat button on overview The config of a project will become more important and usefull for project manager, so adding a shortcut from the overview to setup correclty its project makes sense. This commit adds this button on the overview. Task #34703 --- addons/sale_timesheet/controllers/main.py | 22 +++++++++++++++++-- .../static/src/js/timesheet_plan.js | 14 +++++++----- .../views/hr_timesheet_templates.xml | 4 ++-- 3 files changed, 31 insertions(+), 9 deletions(-) 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/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/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 @@
-
+
+
+ +
+
+
+

Time Management

From b4af54f9c23b65abe50dde3dfb397510ec2fcee4 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Mon, 13 Aug 2018 15:21:57 +0200 Subject: [PATCH 8/8] [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 --- addons/sale_timesheet/models/__init__.py | 1 + addons/sale_timesheet/models/account.py | 58 ++- addons/sale_timesheet/models/product.py | 4 +- addons/sale_timesheet/models/project.py | 137 +++++--- .../models/project_sale_line_employee_map.py | 23 ++ addons/sale_timesheet/models/sale_order.py | 3 +- .../security/ir.model.access.csv | 1 + addons/sale_timesheet/tests/__init__.py | 1 + .../tests/test_project_billing.py | 330 ++++++++++++++++++ .../views/project_task_views.xml | 37 +- .../wizard/project_create_sale_order.py | 95 +++-- .../project_create_sale_order_views.xml | 24 +- 12 files changed, 618 insertions(+), 96 deletions(-) create mode 100644 addons/sale_timesheet/models/project_sale_line_employee_map.py create mode 100644 addons/sale_timesheet/tests/test_project_billing.py 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 a5d9e39d5b6..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,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'] 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 0d06a4d069d..32aaa56806c 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -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}, } 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 d2d24ef3f81..de856098858 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -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 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/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/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index cb631dbd608..6b3d9b4c128 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -1,6 +1,33 @@ + + project.project.form.inherit + project.project + + + +