diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index ebf05c835c2ea..92d019913255f 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -201,11 +201,23 @@ def action_view_subtask_timesheet(self): 'domain': [('project_id', '!=', False), ('task_id', 'in', tasks.ids)], } + def _get_timesheet(self): + # Is override in sale_timesheet + return self.timesheet_ids + def write(self, values): # 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 there are some timesheets linked to it.')) - return super(Task, self).write(values) + res = super(Task, self).write(values) + + if 'project_id' in values: + project = self.env['project.project'].browse(values.get('project_id')) + if project.allow_timesheets: + # We write on all non yet invoiced timesheet the new project_id (if project allow timesheet) + self._get_timesheet().write({'project_id': values.get('project_id')}) + + return res def name_get(self): if self.env.context.get('hr_timesheet_display_remaining_hours'): diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index ab6e6570b88b0..f0cdc38a0da6a 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -214,7 +214,7 @@ def test_create_unlink_project(self): tracked_project.analytic_account_id.unlink() def test_transfert_project(self): - """ Transfert task with timesheet to another project should not modified past timesheets (they are still linked to old project. """ + """ Transfert task with timesheet to another project. """ Timesheet = self.env['account.analytic.line'] # create a second project self.project_customer2 = self.env['project.project'].create({ @@ -242,8 +242,8 @@ def test_transfert_project(self): timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_customer.id)]) timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_customer2.id)]) - self.assertEqual(timesheet_count1, 1, "Still one timesheet in project 1") - self.assertEqual(timesheet_count2, 0, "No timesheet in project 2") + self.assertEqual(timesheet_count1, 0, "No timesheet in project 1") + self.assertEqual(timesheet_count2, 1, "Still one timesheet in project 2") self.assertEqual(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 diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 39d4cd0882e94..b933b65363f90 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -62,7 +62,7 @@ def write(self, values): return result def _check_can_write(self, values): - if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and self.filtered(lambda timesheet: timesheet.timesheet_invoice_id): + if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and self.filtered(lambda t: t.timesheet_invoice_id and t.timesheet_invoice_id.state != 'cancel'): 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).')) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 64053838b112f..a50ecc7afbdcc 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -174,6 +174,7 @@ class ProjectTask(models.Model): ('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') + has_multi_sol = fields.Boolean(compute='_compute_has_multi_sol', compute_sudo=True) allow_billable = fields.Boolean(related="project_id.allow_billable") display_create_order = fields.Boolean(compute='_compute_display_create_order') @@ -191,9 +192,12 @@ def _compute_display_create_order(self): @api.onchange('sale_line_id') def _onchange_sale_line_id(self): - if self.timesheet_ids: + if self._get_timesheet() and self.allow_timesheets: if self.sale_line_id: - message = _("All timesheet hours that are not yet invoiced will be assigned to the selected Sales Order Item on save. Discard to avoid the change.") + if self.sale_line_id.product_id.service_policy == 'delivered_timesheet' and self._origin.sale_line_id.product_id.service_policy == 'delivered_timesheet': + message = _("All timesheet hours that are not yet invoiced will be assigned to the selected Sales Order Item on save. Discard to avoid the change.") + else: + message = _("All timesheet hours will be assigned to the selected Sales Order Item on save. Discard to avoid the change.") else: message = _("All timesheet hours that are not yet invoiced will be removed from the selected Sales Order Item on save. Discard to avoid the change.") @@ -202,6 +206,16 @@ def _onchange_sale_line_id(self): 'message': message }} + @api.onchange('project_id') + def _onchange_project_id(self): + if self._origin.allow_timesheets and self._get_timesheet(): + message = _("All timesheet hours that are not yet invoiced will be assigned to the selected Project on save. Discard to avoid the change.") + + return {'warning': { + 'title': _("Warning"), + 'message': message + }} + @api.depends('analytic_account_id.active') def _compute_analytic_account_active(self): super()._compute_analytic_account_active() @@ -233,6 +247,11 @@ def _compute_is_project_map_empty(self): for task in self: task.is_project_map_empty = not bool(task.sudo().project_id.sale_line_employee_ids) + @api.depends('timesheet_ids') + def _compute_has_multi_sol(self): + for task in self: + task.has_multi_sol = task.timesheet_ids.so_line != task.sale_line_id + @api.onchange('project_id') def _onchange_project(self): super(ProjectTask, self)._onchange_project() @@ -254,12 +273,22 @@ def write(self, values): if project_dest.billable_type == 'employee_rate': values['sale_line_id'] = False res = super(ProjectTask, self).write(values) - if 'sale_line_id' in values and self.sudo().timesheet_ids: - self.timesheet_ids.filtered( + if 'sale_line_id' in values and self.filtered('allow_timesheets').sudo().timesheet_ids: + so = self.env['sale.order.line'].browse(values['sale_line_id']).order_id + if so and not so.analytic_account_id: + so.analytic_account_id = self.project_id.analytic_account_id + timesheet_ids = self.filtered('allow_timesheets').timesheet_ids.filtered( lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') and t.so_line.id == old_sale_line_id[t.task_id.id] - ).write({ - 'so_line': values['sale_line_id'] - }) + ) + timesheet_ids.write({'so_line': values['sale_line_id']}) + if 'project_id' in values: + + # Special case when we edit SOL an project in same time, as we edit SOL of + # timesheet lines, function '_get_timesheet' won't find the right timesheet + # to edit so we must edit those here. + project = self.env['project.project'].browse(values.get('project_id')) + if project.allow_timesheets: + timesheet_ids.write({'project_id': values.get('project_id')}) return res def action_make_billable(self): @@ -277,5 +306,10 @@ def action_make_billable(self): }, } + def _get_timesheet(self): + # return not invoiced timesheet and timesheet without so_line or so_line linked to task + timesheet_ids = super(ProjectTask, self)._get_timesheet() + return timesheet_ids.filtered(lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') and (not t.so_line or t.so_line == t.task_id._origin.sale_line_id)) + def _get_action_view_so_ids(self): return list(set((self.sale_order_id + self.timesheet_ids.so_line.order_id).ids)) diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py index 6a57daf629ecf..80986ba24f885 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -521,3 +521,78 @@ def test_timesheet_invoice(self): self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount + timesheet2.unit_amount + timesheet3.unit_amount) self.assertTrue(so_line_deliver_task_project.invoice_lines) self.assertEqual(so_line_deliver_task_project.qty_invoiced, timesheet4.unit_amount) + + def test_transfert_project(self): + """ Transfert task with timesheet to another project. """ + Timesheet = self.env['account.analytic.line'] + Task = self.env['project.task'] + today = Date.context_today(self.env.user) + + task = Task.with_context(default_project_id=self.project_global.id).create({ + 'name': 'first task', + 'partner_id': self.partner_customer_usd.id, + 'planned_hours': 10, + }) + + Timesheet.create({ + 'project_id': self.project_global.id, + 'task_id': task.id, + 'name': 'my first timesheet', + 'unit_amount': 4, + }) + + timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_global.id)]) + timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_template.id)]) + self.assertEqual(timesheet_count1, 1, "One timesheet in project_global") + self.assertEqual(timesheet_count2, 0, "No timesheet in project_template") + self.assertEqual(len(task.timesheet_ids), 1, "The timesheet should be linked to task") + + # change project of task, as the timesheet is not yet invoiced, the timesheet will change his project + task.write({ + 'project_id': self.project_template.id + }) + + timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_global.id)]) + timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_template.id)]) + self.assertEqual(timesheet_count1, 0, "No timesheet in project_global") + self.assertEqual(timesheet_count2, 1, "One timesheet in project_template") + self.assertEqual(len(task.timesheet_ids), 1, "The timesheet still should be linked to task") + + wizard = self.env['project.task.create.sale.order'].with_context(active_id=task.id, active_model='project.task').create({ + 'product_id': self.product_delivery_timesheet3.id + }) + + # We create the SO and the invoice + action = wizard.action_create_sale_order() + sale_order = self.env['sale.order'].browse(action['res_id']) + self.context = { + 'active_model': 'sale.order', + 'active_ids': [sale_order.id], + 'active_id': sale_order.id, + 'default_journal_id': self.journal_sale.id + } + wizard = self.env['sale.advance.payment.inv'].with_context(self.context).create({ + 'advance_payment_method': 'delivered', + 'date_invoice_timesheet': today + }) + wizard.create_invoices() + + Timesheet.create({ + 'project_id': self.project_template.id, + 'task_id': task.id, + 'name': 'my second timesheet', + 'unit_amount': 6, + }) + + self.assertEqual(Timesheet.search_count([('project_id', '=', self.project_template.id)]), 2, "2 timesheets in project_template") + + # change project of task, the timesheet not yet invoiced will change its project. The timesheet already invoiced will not change his project. + task.write({ + 'project_id': self.project_global.id + }) + + timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_global.id)]) + timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_template.id)]) + self.assertEqual(timesheet_count1, 1, "One timesheet in project_global") + self.assertEqual(timesheet_count2, 1, "Still one timesheet in project_template") + self.assertEqual(len(task.timesheet_ids), 2, "The 2 timesheet still should be linked to task") diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 51e83ea596953..d933a9e872ed9 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -105,10 +105,11 @@ + - +