From ecbeecb06ea668ad53d481d7b4b3bf44f216b04d Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 18 Jan 2021 15:30:04 +0100 Subject: [PATCH 1/8] [IMP] sale_timesheet: add data in setUpClass of TestSaleTimesheetCommon task-2439304 --- addons/sale_timesheet/tests/common.py | 63 ++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py index d17db84ad9b..35bf03c032e 100644 --- a/addons/sale_timesheet/tests/common.py +++ b/addons/sale_timesheet/tests/common.py @@ -67,13 +67,14 @@ class TestCommonSaleTimesheet(TestSaleCommon): }) # Create projects - cls.project_global = cls.env['project.project'].create({ + Project = cls.env['project.project'].with_context(tracking_disable=True) + cls.project_global = Project.create({ 'name': 'Project for selling timesheets', 'allow_timesheets': True, 'analytic_account_id': cls.analytic_account_sale.id, 'allow_billable': True, }) - cls.project_template = cls.env['project.project'].create({ + cls.project_template = Project.create({ 'name': 'Project TEMPLATE for services', 'allow_timesheets': True, }) @@ -82,6 +83,36 @@ class TestCommonSaleTimesheet(TestSaleCommon): 'sequence': 1, 'project_ids': [(4, cls.project_template.id)] }) + # Projects: at least one per billable type + cls.project_task_rate = Project.create({ + 'name': 'Project with pricing_type="task_rate"', + 'allow_timesheets': True, + 'allow_billable': True, + 'partner_id': cls.partner_b.id, + 'analytic_account_id': cls.analytic_account_sale.id, + }) + cls.project_project_rate = cls.project_task_rate.copy({ + 'name': 'Project with pricing_type="project_rate"', + 'pricing_type': 'fixed_rate', + }) + cls.project_employee_rate = cls.project_task_rate.copy({ + 'name': 'Project with pricing_type="employee_rate"', + 'pricing_type': 'employee_rate', + }) + + cls.project_subtask = Project.create({ + 'name': "Sub Task Project (non billable)", + 'allow_timesheets': True, + 'allow_billable': False, + 'partner_id': False, + }) + cls.project_non_billable = Project.create({ + 'name': "Non Billable Project", + 'allow_timesheets': True, + 'allow_billable': False, + 'partner_id': False, + 'subtask_project_id': cls.project_subtask.id, + }) # Create service products uom_hour = cls.env.ref('uom.product_uom_hour') @@ -319,3 +350,31 @@ class TestCommonSaleTimesheet(TestSaleCommon): 'taxes_id': False, 'property_account_income_id': cls.account_sale.id, }) + + def setUp(self): + super().setUp() + self.so = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({ + 'partner_id': self.partner_b.id, + 'partner_invoice_id': self.partner_b.id, + 'partner_shipping_id': self.partner_b.id, + }) + self.env['sale.order.line'].create([{ + 'order_id': self.so.id, + 'name': self.product_delivery_timesheet1.name, + 'product_id': self.product_delivery_timesheet1.id, + 'product_uom_qty': 10, + 'price_unit': self.product_delivery_timesheet1.list_price + }, { + 'order_id': self.so.id, + 'product_id': self.product_delivery_timesheet2.id, + 'product_uom_qty': 5, + }, { + 'order_id': self.so.id, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 5, + }, { + 'order_id': self.so.id, + 'product_id': self.product_order_timesheet1.id, + 'product_uom_qty': 2, + }]) + self.so.action_confirm() From 267412206e1942019c906054085a68c2eb78da65 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 18 Jan 2021 15:26:56 +0100 Subject: [PATCH 2/8] [IMP] sale_timesheet: test the edition of SOL of timesheets This commit adds a test to check if the user can manually edit a SOL in timesheet model. If he do this, then we also check the SOL doesn't change when the SOL in task changes. Also, we check the qty_delivered changed for each SOL impacted. task-2439304 --- addons/sale_timesheet/tests/__init__.py | 1 + .../tests/test_edit_so_line_timesheet.py | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 addons/sale_timesheet/tests/test_edit_so_line_timesheet.py diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index dd7859481f1..4e1cbaa6713 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -10,3 +10,4 @@ from . import test_reporting from . import test_project_overview from . import test_project_billing_multicompany from . import test_upsell_warning +from . import test_edit_so_line_timesheet diff --git a/addons/sale_timesheet/tests/test_edit_so_line_timesheet.py b/addons/sale_timesheet/tests/test_edit_so_line_timesheet.py new file mode 100644 index 00000000000..c4dc931a8b1 --- /dev/null +++ b/addons/sale_timesheet/tests/test_edit_so_line_timesheet.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged + +from .common import TestCommonSaleTimesheet + + +@tagged('-at_install', 'post_install') +class TestEditSoLineTimesheet(TestCommonSaleTimesheet): + def setUp(self): + super().setUp() + self.task_rate_task = self.env['project.task'].create({ + 'name': 'Task', + 'project_id': self.project_task_rate.id, + 'sale_line_id': self.so.order_line[0].id, + }) + + def test_sol_no_change_if_edited(self): + """ Check if a sol manually edited, does not change with a change of sol in the task. + + Test Case: + ========= + 1) create some timesheets on this task, + 2) edit a SOL of a timesheet in this task, + 3) check if the edited SOL has the one selected and is not the one in the task, + 4) change the sol on the task, + 5) check if the timesheet in which the sol has manually edited, does not change but the another ones are the case. + """ + # 1) create some timesheets on this task + timesheet = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': self.project_task_rate.id, + 'task_id': self.task_rate_task.id, + 'unit_amount': 5, + 'employee_id': self.employee_manager.id + }) + timesheet._compute_so_line() + edited_timesheet = timesheet.copy() + self.assertTrue(timesheet.so_line == edited_timesheet.so_line == self.task_rate_task.sale_line_id, "SOL in timesheet should be the same than the one in the task.") + self.assertEqual(timesheet.unit_amount + edited_timesheet.unit_amount, self.task_rate_task.sale_line_id.qty_delivered, "The quantity timesheeted should be increased the quantity delivered in the linked SOL.") + + # 2) edit a SOL of a timesheet in this task + # Remark, we simulate the action done in the task form view + edited_timesheet.write({ + "is_so_line_edited": True, + "so_line": self.so.order_line[1].id, + }) + self.so.order_line._compute_qty_delivered() + + # 3) check if the edited SOL has the one selected and is not the one in the task + self.assertNotEqual(edited_timesheet.so_line, self.task_rate_task.sale_line_id, "SOL in timesheet should be different than the one in the task.") + self.assertEqual(edited_timesheet.so_line, self.so.order_line[1], "SOL in timesheet is the one selected when we manually edit in the timesheet") + self.assertEqual(self.task_rate_task.sale_line_id.qty_delivered, timesheet.unit_amount, "The quantity delivered should be the quantity defined in the first timesheet of the task since the so_line in the second timesheet has manually been changed.") + + # 4) change the sol on the task + self.task_rate_task.update({ + 'sale_line_id': self.so.order_line[-1].id, + }) + timesheet._compute_so_line() + edited_timesheet._compute_so_line() + self.so.order_line._compute_qty_delivered() + + # 5) check if the timesheet in which the sol has manually edited, does not change but the another ones are the case. + self.assertEqual(timesheet.so_line, self.task_rate_task.sale_line_id, "SOL in timesheet should be the same than the one in the task.") + self.assertNotEqual(edited_timesheet.so_line, self.task_rate_task.sale_line_id, "SOL in timesheet which is manually edited should be different than the one in the task.") + self.assertEqual(edited_timesheet.so_line, self.so.order_line[1], "SOL in timesheet should still be the same") From 1980c5ea7af389d279b40f245623f1b822cdca99 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 18 Jan 2021 21:05:30 +0100 Subject: [PATCH 3/8] [IMP] sale_timesheet: test compute SOL for tasks and timesheets This commit adds tests to check the compute SOL for tasks and timesheets based on the configuration of the linked project. task-2439304 --- addons/sale_timesheet/tests/__init__.py | 1 + .../test_so_line_determined_in_timesheet.py | 225 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 addons/sale_timesheet/tests/test_so_line_determined_in_timesheet.py diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index 4e1cbaa6713..3a6404a4e15 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -11,3 +11,4 @@ from . import test_project_overview from . import test_project_billing_multicompany from . import test_upsell_warning from . import test_edit_so_line_timesheet +from . import test_so_line_determined_in_timesheet diff --git a/addons/sale_timesheet/tests/test_so_line_determined_in_timesheet.py b/addons/sale_timesheet/tests/test_so_line_determined_in_timesheet.py new file mode 100644 index 00000000000..78d98e4ca75 --- /dev/null +++ b/addons/sale_timesheet/tests/test_so_line_determined_in_timesheet.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged + +from .common import TestCommonSaleTimesheet + + +@tagged('-at_install', 'post_install') +class TestSoLineDeterminedInTimesheet(TestCommonSaleTimesheet): + + def test_sol_determined_when_project_is_task_rate(self): + """ Test the sol give to the timesheet when the pricing type in the project is task rate + + Test Case: + ========= + 1) Create Task in project with pricing_type='task_rate', + 2) Compute the SOL for the task and check if we have the one containing the prepaid service product, + 3) Create timesheet and check if the SOL in this timesheet is the one in the task, + 4) Since the remaining hours of the prepaid service is equals to 0 hour, + when we create a new task, the SOL in this one should be equal to False + 5) Change the SOL in the task and see if the SOL in the timesheet also changes. + """ + # 1) Create Task in project with pricing_type='task_rate' + task = self.env['project.task'].create({ + 'name': 'Task', + 'project_id': self.project_task_rate.id, + }) + + # 2) Compute the SOL for the task and check if we have the one containing the prepaid service product + # task._compute_sale_line() + self.assertEqual(task.sale_line_id, self.so.order_line[-1], "The SOL in the task should be the one containing the prepaid service product.") + self.assertTrue(all(sol.qty_delivered == 0 for sol in self.so.order_line), "The quantity delivered should be equal to 0 because we have no timesheet for each SOL containing in the SO.") + + # 3) Create timesheet and check if the SOL in this timesheet is the one in the task + timesheet = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'unit_amount': 2, + 'employee_id': self.employee_manager.id, + 'project_id': self.project_task_rate.id, + 'task_id': task.id, + }) + self.assertEqual(timesheet.so_line, task.sale_line_id, "The SOL in the timesheet should be the same than the one in the task.") + self.assertEqual(self.so.order_line[-1].qty_delivered, 2, "The quantity delivered should be equal to 2.") + self.assertEqual(task.remaining_hours_so, 0, "The remaining hours on the SOL containing the prepaid service product should be equals to 0.") + + # 4) Since the remaining hours of the prepaid service product is equals to 0 hour, when we create a new task, the SOL in this one should be equals to False + task2 = self.env['project.task'].create({ + 'name': 'Task 2', + 'project_id': self.project_task_rate.id, + }) + self.assertFalse(task2.sale_line_id, "The SOL in this task should be equal to False") + + # 5) Change the SOL in the task and see if the SOL in the timesheet also changes. + task.update({'sale_line_id': self.so.order_line[0].id}) + self.assertEqual(timesheet.so_line, task.sale_line_id, "The SOL in the timesheet should also change and be the same than the one in the task.") + + def test_sol_determined_when_project_is_project_rate(self): + """ Test the sol give to the timesheet when the pricing type in the project is project rate + + Test Case: + ========= + 1) Define a SO and SOL in the project, + 2) Create task and check if the SOL is the one defined in the project, + 3) Create timesheet in the task and check if the SOL in the timesheet is the one in the task, + 4) Change the SOL in the task and check if the SOL in the timesheet has also changed. + """ + # 1) Define a SO and SOL in the project + self.project_project_rate.write({ + 'sale_order_id': self.so.id, + 'sale_line_id': self.so.order_line[0].id, + }) + + # 2) Create task and check if the SOL is the one defined in the project + task = self.env['project.task'].create({ + 'name': 'Task', + 'project_id': self.project_project_rate.id, + }) + self.assertEqual(task.sale_line_id, self.so.order_line[0], "The SOL in the task should be the one containing the prepaid service product.") + self.assertTrue(all(sol.qty_delivered == 0 for sol in self.so.order_line), "The quantity delivered should be equal to 0 because we have no timesheet for each SOL containing in the SO.") + + # 3) Create timesheet in the task and check if the SOL in the timesheet is the one in the task + timesheet = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'unit_amount': 1, + 'employee_id': self.employee_manager.id, + 'project_id': self.project_project_rate.id, + 'task_id': task.id, + }) + self.assertTrue(timesheet.so_line == task.sale_line_id == self.so.order_line[0], "The SOL in the timesheet should be the same than the one in the task.") + self.assertEqual(self.so.order_line[0].qty_delivered, 1, "The quantity delivered should be equal to 1.") + + # 4) Change the SOL in the task and check if the SOL in the timesheet has also changed + task.update({'sale_line_id': self.so.order_line[1].id}) + self.assertTrue(timesheet.so_line == task.sale_line_id == self.so.order_line[1], "The SOL in the timesheet should also change and be the same than the one in the task.") + + def test_sol_determined_when_project_is_employee_rate(self): + """ Test the sol give to the timesheet when the pricing type in the project is employee rate + + Test Case: + ========= + 1) Define a SO, SOL mapping for an employee in the project, + 2) Create task and check if the SOL is the one defined in the project, + 3) Create timesheet in the task and check if the SOL in the timesheet is the one in the task, + 4) Create timesheet in the task for the employee defined in the mapping and check if the SOL in this timesheet is the one defined in the mapping, + 5) Change the SOL in the task and check if only the SOL in the timesheet which does not concerne about the mapping changes, + 6) Change the SOL in the mapping and check if the timesheet conserne by the mapping has its SOL has been changed too. + """ + # 1) Define a SO, SOL and mapping for an employee in the project, + self.project_employee_rate.write({ + 'sale_order_id': self.so.id, + 'sale_line_id': self.so.order_line[0].id, + 'sale_line_employee_ids': [(0, 0, { + 'employee_id': self.employee_user.id, + 'sale_line_id': self.so.order_line[1].id, + })] + }) + mapping = self.project_employee_rate.sale_line_employee_ids + + # 2) Create task and check if the SOL is the one defined in the project, + task = self.env['project.task'].create({ + 'name': 'Task', + 'project_id': self.project_employee_rate.id, + }) + self.assertEqual(task.sale_line_id, self.so.order_line[0], "The SOL in the task should be the one containing the prepaid service product.") + self.assertTrue(all(sol.qty_delivered == 0 for sol in self.so.order_line), "The quantity delivered should be equal to 0 because we have no timesheet for each SOL containing in the SO.") + + # 3) Create timesheet in the task and check if the SOL in the timesheet is the one in the task, + timesheet = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'unit_amount': 1, + 'employee_id': self.employee_manager.id, + 'project_id': self.project_employee_rate.id, + 'task_id': task.id, + }) + self.assertTrue(timesheet.so_line == task.sale_line_id == self.so.order_line[0], "The SOL in the timesheet should be the same than the one in the task.") + self.assertEqual(self.so.order_line[0].qty_delivered, 1, "The quantity delivered should be equal to 1 for all SOLs in the SO.") + + # 4) Create timesheet in the task for the employee defined in the mapping and check if the SOL in this timesheet is the one defined in the # mapping, + employee_user_timesheet = timesheet.copy({ + 'name': 'Test Line Employee User', + 'employee_id': self.employee_user.id, + 'unit_amount': 2, + }) + employee_user_timesheet._compute_so_line() + self.assertTrue(employee_user_timesheet.so_line == self.project_employee_rate.sale_line_employee_ids[0].sale_line_id == self.so.order_line[1], "The SOL in the timesheet should be the one defined in the mapping for the employee user.") + self.assertEqual(self.so.order_line[1].qty_delivered, 2, "The quantity delivered for this SOL should be equal to 2 hours.") + + # 5) Change the SOL in the task and check if only the SOL in the timesheet which does not concerne about the mapping changes, + task.update({'sale_line_id': self.so.order_line[2].id}) + self.assertTrue(timesheet.so_line == task.sale_line_id == self.so.order_line[2], "The SOL in the timesheet should also change and be the same than the one in the task.") + self.assertNotEqual(timesheet.so_line, employee_user_timesheet.so_line, "The SOL in the timesheet done by the employee user should not be the same than the one in the other timesheet in the task.") + + # 6) Change the SOL in the mapping and check if the timesheet conserne by the mapping has its SOL has been changed too. + mapping.update({'sale_line_id': self.so.order_line[-1].id}) + self.assertTrue(employee_user_timesheet.so_line == mapping.sale_line_id == self.so.order_line[-1], "The SOL in the timesheet done by the employee user should be the one defined in the mapping.") + self.assertNotEqual(timesheet.so_line, employee_user_timesheet.so_line, "The other timesheet should not have the SOL defined in the mapping.") + + def test_no_so_line_if_project_non_billable(self): + """ Test if the timesheet created in task in non billable project does not have a SOL + + Test Case: + ========= + 1) Create task in a non billable project, + 2) Check if there is no SOL in task, + 3) Create timesheet in the task and check if it does not contain any SOL. + """ + # 1) Create task in a non billable project, + task = self.env['project.task'].create({ + 'name': 'Test Task', + 'project_id': self.project_non_billable.id, + 'partner_id': self.partner_a.id, + }) + # 2) Check if there is no SOL in task, + self.assertFalse(task.sale_line_id, 'No SOL should be linked in this task because the project is non billable.') + + # 3) Create timesheet in the task and check if it does not contain any SOL. + timesheet = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'unit_amount': 1, + 'employee_id': self.employee_manager.id, + 'project_id': task.project_id.id, + 'task_id': task.id, + }) + self.assertFalse(timesheet.so_line, 'No SOL should be linked in this timesheet because the project is non billable.') + + def test_tranfer_project(self): + """ Test if the SOL in timesheet is erased if the task of this timesheet changes the project + from a billable project to a non billable project + + Test Case: + ========= + 1) Create task in project_task_rate, + 2) Check if the task has the SOL which contain the prepaid service product, + 3) Create timesheet in this task, + 4) Check if the timesheet contains the same SOL than the task, + 5) Move the task in a non billable project, + 6) Check if the task and timesheet has no SOL. + """ + # 1) Create task in project_task_rate, + task = self.env['project.task'].create({ + 'name': 'Test Task', + 'project_id': self.project_task_rate.id, + }) + + # 2) Check if the task has the SOL which contain the prepaid service product, + self.assertEqual(task.sale_line_id, self.so.order_line[-1], 'The SOL with prepaid service product should be linked to the task.') + + # 3) Create timesheet in this task, + timesheet = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'unit_amount': 1, + 'employee_id': self.employee_manager.id, + 'project_id': task.project_id.id, + 'task_id': task.id, + }) + + # 4) Check if the timesheet contains the same SOL than the task, + self.assertEqual(timesheet.so_line, task.sale_line_id, 'The timesheet should have the same SOL than the task.') + + # 5) Move the task in a non billable project, + task.write({'project_id': self.project_non_billable.id}) + + # 6) Check if the task and timesheet has no SOL. + self.assertFalse(timesheet.so_line, 'No SOL should be linked to the timesheet because the project is non billable') From dd86be3367286997df1451aa74461226645639b3 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Wed, 20 Jan 2021 10:32:01 +0100 Subject: [PATCH 4/8] [IMP] sale_timesheet: test computation of remaining hours This test check if the compute method for the remaining_hours field in the SOL containing a prepaid service product is correct and also the one for the remaining_hours_so field in the task which contains a SOL with prepaid service product. task-2439304 --- .../sale_timesheet/tests/test_sale_service.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 0b199ffb22f..eb737f78d6a 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -600,3 +600,59 @@ class TestSaleService(TestCommonSaleTimesheet): # copy the task task_copy = task.copy() self.assertEqual(task_copy.sale_line_id, task.sale_line_id, "Duplicating task should keep its Sale line") + + def test_remaining_hours_prepaid_services(self): + """ Test if the remaining hours is correctly computed + + Test Case: + ========= + 1) Check the remaining hours in the SOL containing a prepaid service product, + 2) Create task in project with pricing type is equal to "task rate" and has the customer in the SO + and check if the remaining hours is equal to the remaining hours in the SOL, + 3) Create timesheet in the task for this SOL and check if the remaining hours correctly decrease, + 4) Change the SOL in the task and see if the remaining hours is correctly recomputed. + 5) Create without storing the timesheet to check if remaining hours in SOL does not change. + """ + # 1) Check the remaining hours in the SOL containing a prepaid service product + prepaid_service_sol = self.so.order_line.filtered(lambda sol: sol.product_id.service_policy == 'ordered_timesheet') + self.assertEqual(len(prepaid_service_sol), 1, "It should only have one SOL with prepaid service product in this SO.") + self.assertEqual(prepaid_service_sol.remaining_hours, prepaid_service_sol.product_uom_qty - prepaid_service_sol.qty_delivered, "The remaining hours of this SOL should be equal to the ordered quantity minus the delivered quantity.") + + # 2) Create task in project with pricing type is equal to "task rate" and has the customer in the SO + # and check if the remaining hours is equal to the remaining hours in the SOL, + task = self.env['project.task'].create({ + 'name': 'Test task', + 'project_id': self.project_task_rate.id, + }) + self.assertEqual(task.partner_id, self.project_task_rate.partner_id) + self.assertEqual(task.partner_id, self.so.partner_id) + self.assertEqual(task.remaining_hours_so, prepaid_service_sol.remaining_hours) + + # 3) Create timesheet in the task for this SOL and check if the remaining hours correctly decrease + self.env['account.analytic.line'].create({ + 'name': 'Test Timesheet', + 'project_id': self.project_task_rate.id, + 'task_id': task.id, + 'unit_amount': 1, + }) + self.assertEqual(task.remaining_hours_so, 1, "Before the creation of a timesheet, the remaining hours was 2 hours, when we timesheet 1 hour, the remaining hours should be equal to 1 hour.") + self.assertEqual(prepaid_service_sol.remaining_hours, task.remaining_hours_so, "The remaining hours on the SOL should also be equal to 1 hour.") + + # 4) Change the SOL in the task and see if the remaining hours is correctly recomputed. + task.update({ + 'sale_line_id': self.so.order_line[0].id, + }) + self.assertEqual(task.remaining_hours_so, False, "Since the SOL doesn't contain a prepaid service product, the remaining_hours_so should be equal to False.") + self.assertEqual(prepaid_service_sol.remaining_hours, 2, "Since the timesheet on task has the same SOL than the one in the task, the remaining_hours should increase of 1 hour to be equal to 2 hours.") + + # 5) Create without storing the timesheet to check if remaining hours in SOL does not change + timesheet = self.env['account.analytic.line'].new({ + 'name': 'Test Timesheet', + 'project_id': self.project_task_rate.id, + 'task_id': task.id, + 'unit_amount': 1, + 'so_line': prepaid_service_sol.id, + 'is_so_line_edited': True, + }) + self.assertEqual(timesheet.so_line, prepaid_service_sol, "The SOL should be the same than one containing the prepaid service product.") + self.assertEqual(prepaid_service_sol.remaining_hours, 2, "The remaining hours should not change.") From 9f72d70c75b42aa73b3d24e19d705f9ce62a6f9e Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Wed, 20 Jan 2021 11:55:07 +0100 Subject: [PATCH 5/8] [IMP] sale_timesheet: remove project.task.create.sale.order model This commit removes the project.task.create.sale.order model because this wizard is no longer used. Before we use this wizard to create a SO from ticket and task, but in reality this feature is no used. The test using this wizard has been changed to keep the behaviour of it. This test checks if the invoiced timesheet does not change the project when the linked task changes the project. Moreover, the both projects used in this test have been switched because when we invoice the SO the timesheet is in the template project and this one is not billable. Thus, we create no invoice. To create an invoice as we expect, we must have the timesheet in the global project. task-2439304 --- addons/sale_timesheet/__manifest__.py | 1 - .../security/ir.model.access.csv | 1 - .../tests/test_sale_timesheet.py | 89 +++++------ addons/sale_timesheet/wizard/__init__.py | 1 - .../wizard/project_task_create_sale_order.py | 145 ------------------ .../project_task_create_sale_order_views.xml | 46 ------ 6 files changed, 42 insertions(+), 241 deletions(-) delete mode 100644 addons/sale_timesheet/wizard/project_task_create_sale_order.py delete mode 100644 addons/sale_timesheet/wizard/project_task_create_sale_order_views.xml diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 6b3c91b6723..00d5cdeef96 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -31,7 +31,6 @@ have real delivered quantities in sales orders. 'wizard/project_create_sale_order_views.xml', 'wizard/project_create_invoice_views.xml', 'wizard/sale_make_invoice_advance_views.xml', - 'wizard/project_task_create_sale_order_views.xml' ], 'qweb': [ 'static/src/xml/sale_project_templates.xml', diff --git a/addons/sale_timesheet/security/ir.model.access.csv b/addons/sale_timesheet/security/ir.model.access.csv index a391d6610e4..cda5ac60cc7 100644 --- a/addons/sale_timesheet/security/ir.model.access.csv +++ b/addons/sale_timesheet/security/ir.model.access.csv @@ -5,4 +5,3 @@ access_project_sale_line_employee_map_manager,access_project_sale_line_employee_ access_project_create_sale_order,access.project.create.sale.order,model_project_create_sale_order,sales_team.group_sale_salesman,1,1,1,0 access_project_create_sale_order_line,access.project.create.sale.order.line,model_project_create_sale_order_line,sales_team.group_sale_salesman,1,1,1,1 access_project_create_invoice,access.project.create.invoice,model_project_create_invoice,sales_team.group_sale_salesman_all_leads,1,1,1,0 -access_project_task_create_sale_order,access.project.task.create.sale.order,model_project_task_create_sale_order,sales_team.group_sale_salesman,1,1,1,0 diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py index 9e3411e1845..ca1a5e0bcec 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -530,14 +530,15 @@ class TestSaleTimesheet(TestCommonSaleTimesheet): Task = self.env['project.task'] today = Date.context_today(self.env.user) - task = Task.with_context(default_project_id=self.project_global.id).create({ + task = Task.with_context(default_project_id=self.project_template.id).create({ 'name': 'first task', - 'partner_id': self.partner_a.id, + 'partner_id': self.partner_b.id, 'planned_hours': 10, + 'sale_line_id': self.so.order_line[0].id }) Timesheet.create({ - 'project_id': self.project_global.id, + 'project_id': self.project_template.id, 'task_id': task.id, 'name': 'my first timesheet', 'unit_amount': 4, @@ -545,51 +546,11 @@ class TestSaleTimesheet(TestCommonSaleTimesheet): 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(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 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.company_data['default_journal_sale'].id - } - wizard = self.env['sale.advance.payment.inv'].with_context(self.context).create({ - 'advance_payment_method': 'delivered', - 'date_start_invoice_timesheet': today - timedelta(days=4), - 'date_end_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 }) @@ -597,5 +558,39 @@ class TestSaleTimesheet(TestCommonSaleTimesheet): 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") + self.assertEqual(timesheet_count2, 0, "No timesheet in project_template") + self.assertEqual(len(task.timesheet_ids), 1, "The timesheet still should be linked to task") + + # Create an invoice + context = { + 'active_model': 'sale.order', + 'active_ids': [self.so.id], + 'active_id': self.so.id, + 'default_journal_id': self.company_data['default_journal_sale'].id + } + wizard = self.env['sale.advance.payment.inv'].with_context(context).create({ + 'advance_payment_method': 'delivered', + 'date_start_invoice_timesheet': today - timedelta(days=4), + 'date_end_invoice_timesheet': today, + }) + wizard.create_invoices() + + Timesheet.create({ + 'project_id': self.project_global.id, + 'task_id': task.id, + 'name': 'my second timesheet', + 'unit_amount': 6, + }) + + self.assertEqual(Timesheet.search_count([('project_id', '=', self.project_global.id)]), 2, "2 timesheets in project_global") + + # 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_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, 1, "Still one timesheet in project_global") + self.assertEqual(timesheet_count2, 1, "One timesheet in project_template") + self.assertEqual(len(task.timesheet_ids), 2, "The 2 timesheets still should be linked to task") diff --git a/addons/sale_timesheet/wizard/__init__.py b/addons/sale_timesheet/wizard/__init__.py index 42502ae67a9..a2800ce6ba3 100644 --- a/addons/sale_timesheet/wizard/__init__.py +++ b/addons/sale_timesheet/wizard/__init__.py @@ -4,4 +4,3 @@ from . import project_create_sale_order from . import project_create_invoice from . import sale_make_invoice_advance -from . import project_task_create_sale_order diff --git a/addons/sale_timesheet/wizard/project_task_create_sale_order.py b/addons/sale_timesheet/wizard/project_task_create_sale_order.py deleted file mode 100644 index 723ea2111c4..00000000000 --- a/addons/sale_timesheet/wizard/project_task_create_sale_order.py +++ /dev/null @@ -1,145 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ -from odoo.exceptions import UserError - - -class ProjectTaskCreateSalesOrder(models.TransientModel): - _name = 'project.task.create.sale.order' - _description = "Create SO from task" - - @api.model - def default_get(self, fields): - result = super(ProjectTaskCreateSalesOrder, self).default_get(fields) - - active_model = self._context.get('active_model') - if active_model != 'project.task': - raise UserError(_("You can only apply this action from a task.")) - - active_id = self._context.get('active_id') - if 'task_id' in fields and active_id: - task = self.env['project.task'].browse(active_id) - if task.sale_order_id: - raise UserError(_("The task has already a sale order.")) - result['task_id'] = active_id - if not result.get('partner_id', False): - result['partner_id'] = task.partner_id.id - return result - - link_selection = fields.Selection([('create', 'Create a new sales order'), ('link', 'Link to an existing sales order')], required=True, default='create') - - task_id = fields.Many2one('project.task', "Task", domain=[('sale_line_id', '=', False)], help="Task for which we are creating a sales order", required=True) - partner_id = fields.Many2one('res.partner', string="Customer", help="Customer of the sales order", required=True) - product_id = fields.Many2one('product.product', domain=[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'timesheet')], string="Service", help="Product of the sales order item. Must be a service invoiced based on timesheets on tasks. The existing timesheet will be linked to this product.", required=True) - price_unit = fields.Float("Unit Price", help="Unit price of the sales order item.") - currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id', readonly=False) - commercial_partner_id = fields.Many2one(related='partner_id.commercial_partner_id') - sale_order_id = fields.Many2one( - 'sale.order', string="Sales Order", - domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]") - sale_line_id = fields.Many2one( - 'sale.order.line', 'Sales Order Item', - domain="[('is_service', '=', True), ('order_partner_id', 'child_of', commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_id', '=?', sale_order_id)]") - - info_invoice = fields.Char(compute='_compute_info_invoice') - - @api.depends('sale_line_id', 'price_unit', 'link_selection') - def _compute_info_invoice(self): - for line in self: - domain = self.env['sale.order.line']._timesheet_compute_delivered_quantity_domain() - timesheet = self.env['account.analytic.line'].read_group(domain + [('task_id', '=', self.task_id.id), ('so_line', '=', False), ('timesheet_invoice_id', '=', False)], ['unit_amount'], ['task_id']) - unit_amount = round(timesheet[0].get('unit_amount', 0), 2) if timesheet else 0 - if not unit_amount: - line.info_invoice = False - continue - company_uom = self.env.company.timesheet_encode_uom_id - label = _("hours") - if company_uom == self.env.ref('uom.product_uom_day'): - label = _("days") - if line.link_selection == 'create' and line.price_unit: - line.info_invoice = _("%(amount)s %(label)s will be added to the new Sales Order.", amount=unit_amount, label=label) - else: - line.info_invoice = _("%(amount)s %(label)s will be added to the selected Sales Order.", amount=unit_amount, label=label) - - @api.onchange('product_id') - def _onchange_product_id(self): - if self.product_id: - self.price_unit = self.product_id.lst_price - else: - self.price_unit = 0.0 - - @api.onchange('partner_id') - def _onchange_partner_id(self): - self.sale_order_id = False - self.sale_line_id = False - - @api.onchange('sale_order_id') - def _onchange_sale_order_id(self): - self.sale_line_id = False - - def action_link_sale_order(self): - # link task to SOL - self.task_id.write({ - 'sale_line_id': self.sale_line_id.id, - 'partner_id': self.partner_id.id, - 'email_from': self.partner_id.email, - }) - - # assign SOL to timesheets - self.env['account.analytic.line'].search([('task_id', '=', self.task_id.id), ('so_line', '=', False), ('timesheet_invoice_id', '=', False)]).write({ - 'so_line': self.sale_line_id.id - }) - - def action_create_sale_order(self): - sale_order = self._prepare_sale_order() - sale_order.action_confirm() - view_form_id = self.env.ref('sale.view_order_form').id - action = self.env["ir.actions.actions"]._for_xml_id("sale.action_orders") - action.update({ - 'views': [(view_form_id, 'form')], - 'view_mode': 'form', - 'name': sale_order.name, - 'res_id': sale_order.id, - }) - return action - - def _prepare_sale_order(self): - # if task linked to SO line, then we consider it as billable. - if self.task_id.sale_line_id: - raise UserError(_("The task is already linked to a sales order item.")) - - # create SO - sale_order = self.env['sale.order'].create({ - 'partner_id': self.partner_id.id, - 'company_id': self.task_id.company_id.id, - 'analytic_account_id': self.task_id.project_id.analytic_account_id.id, - }) - sale_order.onchange_partner_id() - sale_order.onchange_partner_shipping_id() - # rewrite the user as the onchange_partner_id erases it - sale_order.write({'user_id': self.task_id.user_id.id}) - sale_order.onchange_user_id() - - sale_order_line = self.env['sale.order.line'].create({ - 'order_id': sale_order.id, - 'product_id': self.product_id.id, - 'price_unit': self.price_unit, - 'project_id': self.task_id.project_id.id, # prevent to re-create a project on confirmation - 'task_id': self.task_id.id, - 'product_uom_qty': round(sum(self.task_id.timesheet_ids.filtered(lambda t: not t.so_line).mapped('unit_amount')), 2), - }) - - # link task to SOL - self.task_id.write({ - 'sale_line_id': sale_order_line.id, - 'partner_id': sale_order.partner_id.id, - 'email_from': sale_order.partner_id.email, - }) - - # assign SOL to timesheets - self.env['account.analytic.line'].search([('task_id', '=', self.task_id.id), ('so_line', '=', False), ('timesheet_invoice_id', '=', False)]).write({ - 'so_line': sale_order_line.id - }) - - return sale_order diff --git a/addons/sale_timesheet/wizard/project_task_create_sale_order_views.xml b/addons/sale_timesheet/wizard/project_task_create_sale_order_views.xml deleted file mode 100644 index 2521b53bd36..00000000000 --- a/addons/sale_timesheet/wizard/project_task_create_sale_order_views.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - project.task.create.sale.order.wizard.form - project.task.create.sale.order - -
- - - - - - - - - - - - - - - - - - - - -
-
- -
-
- - - Create a Sales Order - project.task.create.sale.order - form - - new - - -
From 5ff4557b9845670c1cc6819aadfd61bb05f70b2f Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Wed, 20 Jan 2021 12:44:29 +0100 Subject: [PATCH 6/8] [IMP] sale_timesheet: test when partner_id is changed in project This commit add a test for project with pricing_type in ['fixed_rate', 'employee_rate']. This test checks when the user changes the partner_id in the project, if the SO and SOL in project and also in mapping are removed if the customer selected isn't the one in the SO. task-2439304 --- .../tests/test_project_billing.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/addons/sale_timesheet/tests/test_project_billing.py b/addons/sale_timesheet/tests/test_project_billing.py index 787f06654bb..fa43319d612 100644 --- a/addons/sale_timesheet/tests/test_project_billing.py +++ b/addons/sale_timesheet/tests/test_project_billing.py @@ -93,6 +93,11 @@ class TestProjectBilling(TestCommonSaleTimesheet): 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_project_rate.write({ + 'sale_order_id': cls.sale_order_1.id, + 'sale_line_id': cls.so1_line_order_no_task.id, + }) + cls.project_employee_rate = Project.create({ 'name': "Project billed at Employee Rate", 'allow_timesheets': True, @@ -409,3 +414,30 @@ class TestProjectBilling(TestCommonSaleTimesheet): 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") + + def test_customer_change_in_project(self): + """ Test when the user change the customer in a project + + Test Case: + ========= + 1) Take project with pricing_type="fixed_rate", change the existing customer to another and check if the SO and SOL are equal to False. + 2) Take project with pricing_type="employee_rate", change the existing customer to another and check if the SO and SOL are equal to False. + 2.1) Check if the SOL in mapping is also equal to False + """ + # 1) Take project with pricing_type="fixed_rate", change the existing customer to another and check if the SO and SOL are equal to False. + self.project_project_rate.write({ + 'partner_id': self.partner_2.id, + }) + self.assertFalse(self.project_project_rate.sale_order_id, "The SO in the project should be False because the previous SO does not for the actual customer of the project.") + self.assertFalse(self.project_project_rate.sale_line_id, "The SOL in the project should be False because the SO is removed too.") + + # 2) Take project with pricing_type="employee_rate", change the existing customer to another and check if the SO and SOL are equal to False. + self.project_employee_rate.write({ + 'partner_id': self.partner_2.id, + }) + self.assertFalse(self.project_employee_rate.sale_order_id, "The SO in the project should be False because the previous SO does not for the actual customer of the project.") + self.assertFalse(self.project_employee_rate.sale_line_id, "The SOL in the project should be False because the SO is removed too.") + + # 2.1) Check if the SOL in mapping is also equal to False + self.assertFalse(self.project_employee_rate_manager.sale_line_id, "The SOL in the mapping should be False because the actual customer in the project has not this SOL.") + self.assertFalse(self.project_employee_rate_user.sale_line_id, "The SOL in the mapping should be False because the actual customer in the project has not this SOL.") From 1d6735e01557822203176a2b5e6afcead0de82c7 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Thu, 21 Jan 2021 15:44:20 +0100 Subject: [PATCH 7/8] [IMP] sale_timesheet: update the existing unit tests task-2439304 --- .../tests/test_project_billing.py | 36 +++++-------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/addons/sale_timesheet/tests/test_project_billing.py b/addons/sale_timesheet/tests/test_project_billing.py index fa43319d612..4264084c126 100644 --- a/addons/sale_timesheet/tests/test_project_billing.py +++ b/addons/sale_timesheet/tests/test_project_billing.py @@ -75,21 +75,6 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) 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, - 'allow_billable': False, - 'partner_id': False, - }) - cls.project_non_billable = Project.create({ - 'name': "Non Billable Project", - 'allow_timesheets': True, - 'allow_billable': False, - '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) @@ -97,12 +82,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'sale_order_id': cls.sale_order_1.id, 'sale_line_id': cls.so1_line_order_no_task.id, }) - - cls.project_employee_rate = Project.create({ - 'name': "Project billed at Employee Rate", - 'allow_timesheets': True, - 'allow_billable': True, - 'pricing_type': 'employee_rate', + cls.project_employee_rate.write({ 'sale_order_id': cls.sale_order_1.id, 'partner_id': cls.sale_order_1.partner_id.id, 'subtask_project_id': cls.project_subtask.id, @@ -263,7 +243,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): self.assertTrue(task.allow_billable, "Task in project 'employee rate' should be billable") self.assertEqual(task.pricing_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.sale_line_id, self.project_employee_rate.sale_line_id, "Task created in a project billed on 'employee rate' should be linked to the SOL defined in 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 @@ -308,7 +288,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): task._onchange_project() self.assertTrue(task.allow_billable, "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.sale_line_id, self.so2_line_deliver_project_task, "The SOL in the task should be the one that we selected") 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 @@ -316,8 +296,8 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'project_id': self.project_task_rate2.id, }) - self.assertTrue(task.allow_billable, "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") + self.assertTrue(subtask.allow_billable, "Subtask should keep the billable type from its parent, even when they are moved into another project") + self.assertEqual(subtask.sale_line_id, task.sale_line_id, "Subtask should keep the same sale order line than their mother, even when they are moved into another project") # create a second task in employee rate project task2 = Task.with_context(default_project_id=self.project_employee_rate.id).create({ @@ -365,8 +345,8 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) task._onchange_project() - 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") + self.assertEqual(task.sale_line_id, self.so2_line_deliver_project_task, "Task created in a project billed on 'task rate' should be linked to a SOL containing a prepaid service product and the remaining hours of this SOL should be greater than 0.") + self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'task rate' should have the same customer as the one from the project") # log timesheet on task timesheet1 = Timesheet.create({ @@ -377,7 +357,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): '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") + self.assertEqual(task.sale_line_id, timesheet1.so_line, "The timesheet should be linked to the SOL associated to the task since the pricing type of the project is task rate.") # create a subtask subtask = Task.with_context(default_project_id=self.project_task_rate.subtask_project_id.id).create({ From e2a3a04ccf23706b379d3aebf48b60e8c1abe7d4 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Fri, 22 Jan 2021 15:59:33 +0100 Subject: [PATCH 8/8] [IMP] sale_timesheet: add tour js to check the process in the UI This tour js is used to check if the process is not broken. To do this, we create an unit test to launch this tour js. Before launching this test we create a prepaid service product in the unit test. Steps of this tour js: 1) Go to the Sales app, 2) Create a Sales Order with the prepaid service product and we order 10 hours for this product. 3) Go to the Project App, 4) Create a billable project to timesheet for the customer who orders these hours, 5) Create task in this project and add a timesheet into it, 6) Show in this task, we can see the Sales Order Item linked to the created timesheet, because this field is hidden by default. 7) Go to the Settings > Projects, 8) Create a new project and show the three different configurations to invoice services in a project. That is, 'task rate', 'project rate' and 'employee rate'. task-2439304 --- .../static/tests/tours/sale_timesheet_tour.js | 262 ++++++++++++++++++ addons/sale_timesheet/tests/__init__.py | 1 + .../tests/test_sale_timesheet_ui.py | 28 ++ .../views/hr_timesheet_templates.xml | 6 + 4 files changed, 297 insertions(+) create mode 100644 addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js create mode 100644 addons/sale_timesheet/tests/test_sale_timesheet_ui.py diff --git a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js new file mode 100644 index 00000000000..e178275adcf --- /dev/null +++ b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js @@ -0,0 +1,262 @@ +odoo.define('sale_timesheet.tour', function (require) { +"use strict"; + +const tour = require('web_tour.tour'); + +tour.register('sale_timesheet_tour', { + test: true, + url: '/web', +}, [...tour.stepUtils.goToAppSteps("sale.sale_menu_root", 'Go to the Sales App'), +{ + trigger: 'button.o_list_button_add', + content: 'Click on CREATE button to create a quotation with service products.', + run: 'click', +}, { + trigger: 'div[name="partner_id"]', + content: 'Add the customer for this quotation (e.g. Brandon Freeman)', + run: function (actions) { + actions.text('Brandon Freeman', this.$anchor.find('input')); + }, +}, { + trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a', + content: 'Select the first item on the autocomplete dropdown', + run: 'click', +}, +{ + trigger: 'td.o_field_x2many_list_row_add > a:first-child', + content: 'Click on "Add a product" to add a new product. We will add a service product.', + run: 'click', +}, { + trigger: '.o_field_widget[name="product_id"], .o_field_widget[name="product_template_id"]', + content: 'Select a prepaid service product (e.g. Service Product (Prepaid Hours))', + run: function (actions) { + actions.text('Service Product (Prepaid Hours)', this.$anchor.find('input')); + }, +}, { + trigger: 'ul.ui-menu.ui-widget.ui-autocomplete > li:first-child > a', + content: 'Select the prepaid service product in the autocomplete dropdown', + run: 'click', +}, { + trigger: 'input[name="product_uom_qty"]', + content: "Add 10 hours as ordered quantity for this product.", + run: 'text 10', +}, { + trigger: 'button[name="action_confirm"]', + content: 'Click on Confirm button to create a sale order with this quotation.', + run: 'click', +}, { + trigger: 'button.o_form_button_save', + content: 'Click on Save button to save the Sales Order.', + run: 'click', +}, tour.stepUtils.toggleHomeMenu(), +...tour.stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project app.'), +{ + trigger: 'button.o-kanban-button-new', + content: 'Add a new project.', + run: 'click', +}, { + trigger: 'input.o_field_widget.o_project_name', + content: 'Select your project name (e.g. Project for Freeman)', + run: function (actions) { + actions.text('Project for Freeman', this.$anchor); + }, +}, { + trigger: 'div[name="allow_billable"] input', + content: 'Click on the checkbox to have a billable project.', + run: 'click', +}, { + trigger: 'button[name="action_view_tasks"]', + content: 'Click on Create button to create and enter to this newest project.', + run: 'click', +}, { + trigger: 'div.o_kanban_header > div:first-child', + content: 'Select a name of your kanban column (e.g. To Do)', + run: function (actions) { + actions.text('To Do', this.$anchor.find('input')); + }, +}, { + trigger: 'button.o_kanban_add', + content: 'Click on Add button to create the column.', + run: 'click', +}, { + trigger: 'button.o-kanban-button-new', + content: 'Click on Create button to create a task into your project.', + run: 'click', +}, { + trigger: 'input[name="name"]', + content: 'Select the name of the task (e.g. Onboarding)', + run: function (actions) { + actions.text('Onboarding', this.$anchor); + } +}, { + trigger: 'button.o_kanban_edit', + content: 'Click on Edit button to enter to the form view of the task.', + position: 'bottom', + run: 'click', +}, { + trigger: 'div[name="partner_id"]', + content: 'Select the customer of your Sales Order (e.g. Brandon Freeman). Since we have a Sales Order for this customer with a prepaid service product which the remaining hours to deliver is greater than 0, the Sales Order Item in the task should be contain the Sales Order Item containing this prepaid service product.', + run: function (actions) { + actions.text('Brandon Freeman', this.$anchor.find('input')); + }, +}, { + trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a', + content: 'Select the customer in the autocomplete dropdown.', + run: 'click', +}, { + trigger: 'div.o_notebook_headers', + content: 'Click on Timesheets page to log a timesheet', + run: function (actions) { + const notebookId = $('div[name="timesheet_ids"]').closest("div.tab-pane").attr('id'); + actions.click(this.$anchor.find(`a[data-toggle="tab"][href="#${notebookId}"]`)); + }, +}, { + trigger: 'div[name="timesheet_ids"] td.o_field_x2many_list_row_add a[role="button"]', + content: 'Click on Add a line to create a new timesheet into the task.', + run: 'click', +}, { + trigger: 'input[name="unit_amount"]', + content: 'Enter one hour for this timesheet', + run: function (actions) { + actions.text('1', this.$anchor); + }, +}, { + trigger: 'i.o_optional_columns_dropdown_toggle', + content: 'The so_line field should be hidden by default. We check if it is the case by adding this field in the timesheet list view', + run: 'click', +}, { + trigger: 'input[name="so_line"]', + content: 'Check the so_line field to display the column on the list view.', + run: function (actions) { + if (!this.$anchor.prop('checked')) { + actions.click(this.$anchor); + } + }, +}, { + trigger: 'button[name="action_view_so"]', + content: 'Click on this stat button to see the SO linked to the SOL of the task.', + run: 'click', +}, { + trigger: 'div[name="order_line"]', + content: 'Check if the quantity delivered is equal to 1 hour.', + run: function () { + const $header = this.$anchor.find('thead > tr'); + if (!$header || $header.length === 0) + console.error('No Sales Order Item is found in the Sales Order.'); + const tr = $header[0]; + let index = -1; + for (let i = 0; i < tr.children.length; i++) { + const th = tr.children.item(i); + if (th.dataset && th.dataset.name === 'qty_delivered') + index = i; + } + const qtyDelivered = this.$anchor.find(`tbody > tr:first-child > td.o_data_cell:eq(${index})`).text(); + if (qtyDelivered !== "1.00") + console.error('The quantity delivered on this Sales Order Item should be equal to 1.00 hour. qtyDelivered = ' + qtyDelivered); + }, +}, { + trigger: 'a[data-menu-xmlid="project.menu_project_config"]', + content: 'Click on the Configuration menu.', + run: 'click', +}, { + trigger: 'a[role="menuitem"][data-menu-xmlid="project.menu_projects_config"]', + content: 'Select Configuration > Projects.', + run: 'click', +}, { + trigger: 'button.o_list_button_add', + content: 'Click on Create button to create a new project and see the different configuration available for the project.', + run: 'click', +}, { + trigger: 'div.o_notebook_headers', + content: 'Click on Settings page to check the allow_billable checkbox', + run: function (actions) { + const notebookId = $('div[name="allow_billable"]').closest("div.tab-pane").attr('id'); + actions.click(this.$anchor.find(`a[data-toggle="tab"][href="#${notebookId}"]`)); + }, +}, { + trigger: 'div[name="allow_billable"] > input', + content: 'Check the allow_billable', + run: function (actions) { + if (!this.$anchor.prop('checked')) { + actions.click(this.$anchor); + } + } +}, { + trigger: 'div.o_notebook_headers', + content: 'Click on Invoicing tab to configure the invoicing of this project.', + run: function (actions) { + const notebookId = $('div[name="pricing_type"]').closest("div.tab-pane").attr('id'); + actions.click(this.$anchor.find(`a[data-toggle="tab"][href="#${notebookId}"]`)); + }, +}, { + trigger: 'div[name="pricing_type"]', + content: 'Check if the pricing type is equal to Task Rate', + run: function (actions) { + const pricingType = this.$anchor.find('input[data-value="task_rate"]').attr('checked'); + if (!pricingType) + console.error('The default pricing type of the project should be "Task Rate".'); + } +}, { + trigger: 'input[data-value="fixed_rate"]', + content: 'Change the pricing type to "Project Rate"', + run: 'click', +}, { + trigger: 'div[name="partner_id"]', + content: 'Add the customer for this project to select an SO and SOL for this customer (e.g. Brandon Freeman).', + run: function (actions) { + actions.text('Azure Interior, Brandon Freeman', this.$anchor.find('input')); + }, +}, { + trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a', + content: 'Select the customer in the autocomplete dropdown', + run: 'click', +}, { + trigger: 'div[name="sale_order_id"]', + content: 'Select the first Sales Order for this customer to select a SOL.', + run: function (actions) { + actions.text('S', this.$anchor.find('input')); + }, +}, { + trigger: 'ul.ui-autocomplete > li:first-child > a', + content: 'Select the Sales Order in autocomplete dropdown.', + run: 'click', +}, { + trigger: 'div[name="sale_line_id"]', + content: 'Select a Sales Order Item as Default Sales Order Item for each task in this project.', + run: function (actions) { + actions.text('S', this.$anchor.find('input')); + }, +}, { + trigger: 'ul.ui-autocomplete > li:first-child > a', + content: 'Select the Sales Order Item in the autocomplete dropdown.', + run: 'click', +}, { + trigger: 'input[data-value="employee_rate"]', + content: 'Select "Employee rate" as pricing type.', + run: 'click', +}, { + trigger: 'div[name="sale_line_employee_ids"] td.o_field_x2many_list_row_add > a[role="button"]', + content: 'Click on Add a line on the mapping list view.', + run: 'click', +}, { + trigger: 'div[name="sale_line_employee_ids"] div[name="employee_id"] input', + content: 'Select an employee to link a Sales Order Item on his timesheets into this project.', + run: 'click', +}, { + trigger: 'ul.ui-autocomplete > li:first-child > a', + content: 'Select the first employee in the autocomplete dropdown', + run: 'click', +}, { + trigger: 'div[name="sale_line_employee_ids"] div[name="sale_line_id"]', + content: 'Select the Sales Order Item to link to the timesheets of this employee.', + position: 'bottom', + run: function (actions) { + actions.text('S', this.$anchor.find('input')); + }, +}, { + trigger: 'ul.ui-autocomplete > li:first-child > a', + content: 'Select the first Sales Order Item in the autocomplete dropdown.', + run: 'click', +}]); + +}); diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index 3a6404a4e15..5307dbcf137 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -12,3 +12,4 @@ from . import test_project_billing_multicompany from . import test_upsell_warning from . import test_edit_so_line_timesheet from . import test_so_line_determined_in_timesheet +from . import test_sale_timesheet_ui diff --git a/addons/sale_timesheet/tests/test_sale_timesheet_ui.py b/addons/sale_timesheet/tests/test_sale_timesheet_ui.py new file mode 100644 index 00000000000..76a1a45dab0 --- /dev/null +++ b/addons/sale_timesheet/tests/test_sale_timesheet_ui.py @@ -0,0 +1,28 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +# -*- coding: utf-8 -*- + +from odoo.tests import HttpCase, tagged + + +@tagged('-at_install', 'post_install') +class TestUi(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + uom_hour_id = cls.env.ref('uom.product_uom_hour').id + cls.prepaid_service_product = cls.env['product.product'].create({ + 'name': 'Service Product (Prepaid Hours)', + 'categ_id': cls.env.ref('product.product_category_3').id, + 'type': 'service', + 'list_price': 250.00, + 'standard_price': 190.00, + 'uom_id': uom_hour_id, + 'uom_po_id': uom_hour_id, + 'service_policy': 'ordered_timesheet', + 'service_tracking': 'no', + }) + + def test_ui(self): + self.start_tour('/web', 'sale_timesheet_tour', login='admin', timeout=100) diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 3bd71a25685..af45b19f0ed 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -9,6 +9,12 @@ + + Timesheet Plan qweb