From 14b9347343dd88540e34d5501a8b1ca72010371f Mon Sep 17 00:00:00 2001 From: jbm-odoo Date: Wed, 12 Feb 2020 09:31:22 +0000 Subject: [PATCH] [IMP] hr_timesheet: Move not yet invoiced timesheets when project changes When we change the project on task, we change it also on timesheet line if this line is not yet invoiced and if it has no sale order line or the sale order line is the same as sol of task. closes odoo/odoo#45154 Taskid: 2169290 Related: odoo/enterprise#9471 Signed-off-by: Yannick Tivisse (yti) --- addons/hr_timesheet/models/project.py | 14 +++- addons/hr_timesheet/tests/test_timesheet.py | 6 +- addons/sale_timesheet/models/account.py | 2 +- addons/sale_timesheet/models/project.py | 48 ++++++++++-- .../tests/test_sale_timesheet.py | 75 +++++++++++++++++++ .../views/project_task_views.xml | 3 +- 6 files changed, 135 insertions(+), 13 deletions(-) diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index ebf05c835c2..92d01991325 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -201,11 +201,23 @@ class Task(models.Model): '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 ab6e6570b88..f0cdc38a0da 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -214,7 +214,7 @@ class TestTimesheet(TestCommonTimesheet): 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 @@ 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.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 39d4cd0882e..b933b65363f 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -62,7 +62,7 @@ class AccountAnalyticLine(models.Model): 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 64053838b11..a50ecc7afbd 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 @@ class ProjectTask(models.Model): @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 @@ class ProjectTask(models.Model): '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 @@ class ProjectTask(models.Model): 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 @@ class ProjectTask(models.Model): 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 @@ class ProjectTask(models.Model): }, } + 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 6a57daf629e..80986ba24f8 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -521,3 +521,78 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): 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 51e83ea5969..d933a9e872e 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -105,10 +105,11 @@ + - +