diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/sale_timesheet/tests | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/sale_timesheet/tests')
| -rw-r--r-- | addons/sale_timesheet/tests/__init__.py | 11 | ||||
| -rw-r--r-- | addons/sale_timesheet/tests/common.py | 321 | ||||
| -rw-r--r-- | addons/sale_timesheet/tests/test_project_billing.py | 421 | ||||
| -rw-r--r-- | addons/sale_timesheet/tests/test_project_billing_multicompany.py | 36 | ||||
| -rw-r--r-- | addons/sale_timesheet/tests/test_project_overview.py | 130 | ||||
| -rw-r--r-- | addons/sale_timesheet/tests/test_reinvoice.py | 285 | ||||
| -rw-r--r-- | addons/sale_timesheet/tests/test_reporting.py | 399 | ||||
| -rw-r--r-- | addons/sale_timesheet/tests/test_sale_service.py | 599 | ||||
| -rw-r--r-- | addons/sale_timesheet/tests/test_sale_timesheet.py | 601 |
9 files changed, 2803 insertions, 0 deletions
diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py new file mode 100644 index 00000000..4f78edee --- /dev/null +++ b/addons/sale_timesheet/tests/__init__.py @@ -0,0 +1,11 @@ +# # -*- coding: utf-8 -*- +# # Part of Odoo. See LICENSE file for full copyright and licensing details. + +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 +from . import test_project_overview +from . import test_project_billing_multicompany diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py new file mode 100644 index 00000000..d17db84a --- /dev/null +++ b/addons/sale_timesheet/tests/common.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.sale.tests.common import TestSaleCommon + + +class TestCommonSaleTimesheet(TestSaleCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.user_employee_company_B = mail_new_test_user( + cls.env, + name='Gregor Clegane Employee', + login='gregor', + email='gregor@example.com', + notification_type='email', + groups='base.group_user', + company_id=cls.company_data_2['company'].id, + company_ids=[cls.company_data_2['company'].id], + ) + cls.user_manager_company_B = mail_new_test_user( + cls.env, + name='Cersei Lannister Manager', + login='cersei', + email='cersei@example.com', + notification_type='email', + groups='base.group_user', + company_id=cls.company_data_2['company'].id, + company_ids=[cls.company_data_2['company'].id, cls.env.company.id], + ) + + cls.employee_user = cls.env['hr.employee'].create({ + 'name': 'Employee User', + 'timesheet_cost': 15, + }) + cls.employee_manager = cls.env['hr.employee'].create({ + 'name': 'Employee Manager', + 'timesheet_cost': 45, + }) + + cls.employee_company_B = cls.env['hr.employee'].create({ + 'name': 'Gregor Clegane', + 'user_id': cls.user_employee_company_B.id, + 'timesheet_cost': 15, + }) + + cls.manager_company_B = cls.env['hr.employee'].create({ + 'name': 'Cersei Lannister', + 'user_id': cls.user_manager_company_B.id, + 'timesheet_cost': 45, + }) + + # Account and project + cls.account_sale = cls.company_data['default_account_revenue'] + cls.analytic_account_sale = cls.env['account.analytic.account'].create({ + 'name': 'Project for selling timesheet - AA', + 'code': 'AA-2030', + 'company_id': cls.company_data['company'].id, + }) + cls.analytic_account_sale_company_B = cls.env['account.analytic.account'].create({ + 'name': 'Project for selling timesheet Company B - AA', + 'code': 'AA-2030', + 'company_id': cls.company_data_2['company'].id, + }) + + # Create projects + cls.project_global = cls.env['project.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({ + 'name': 'Project TEMPLATE for services', + 'allow_timesheets': True, + }) + cls.project_template_state = cls.env['project.task.type'].create({ + 'name': 'Only stage in project template', + 'sequence': 1, + 'project_ids': [(4, cls.project_template.id)] + }) + + # Create service products + uom_hour = cls.env.ref('uom.product_uom_hour') + + # -- ordered quantities (ordered, timesheet) + cls.product_order_timesheet1 = cls.env['product.product'].create({ + 'name': "Service Ordered, create no task", + 'standard_price': 11, + 'list_price': 13, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-ORDERED1', + 'service_type': 'timesheet', + 'service_tracking': 'no', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_order_timesheet2 = cls.env['product.product'].create({ + 'name': "Service Ordered, create task in global project", + 'standard_price': 30, + 'list_price': 90, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-ORDERED2', + 'service_type': 'timesheet', + 'service_tracking': 'task_global_project', + 'project_id': cls.project_global.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_order_timesheet3 = cls.env['product.product'].create({ + 'name': "Service Ordered, create task in new project", + 'standard_price': 10, + 'list_price': 20, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-ORDERED3', + 'service_type': 'timesheet', + 'service_tracking': 'task_in_project', + 'project_id': False, # will create a project + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_order_timesheet4 = cls.env['product.product'].create({ + 'name': "Service Ordered, create project only", + 'standard_price': 15, + 'list_price': 30, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-ORDERED4', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_order_timesheet5 = cls.env['product.product'].create({ + 'name': "Service Ordered, create project only based on template", + 'standard_price': 17, + 'list_price': 34, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': cls.env.ref('uom.product_uom_hour').id, + 'uom_po_id': cls.env.ref('uom.product_uom_hour').id, + 'default_code': 'SERV-ORDERED4', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_id': False, + 'project_template_id': cls.project_template.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + + # -- timesheet on tasks (delivered, timesheet) + cls.product_delivery_timesheet1 = cls.env['product.product'].create({ + 'name': "Service delivered, create no task", + 'standard_price': 11, + 'list_price': 13, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-DELI1', + 'service_type': 'timesheet', + 'service_tracking': 'no', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_timesheet2 = cls.env['product.product'].create({ + 'name': "Service delivered, create task in global project", + 'standard_price': 30, + 'list_price': 90, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-DELI2', + 'service_type': 'timesheet', + 'service_tracking': 'task_global_project', + 'project_id': cls.project_global.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_timesheet3 = cls.env['product.product'].create({ + 'name': "Service delivered, create task in new project", + 'standard_price': 10, + 'list_price': 20, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-DELI3', + 'service_type': 'timesheet', + 'service_tracking': 'task_in_project', + 'project_id': False, # will create a project + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_timesheet4 = cls.env['product.product'].create({ + 'name': "Service delivered, create project only", + 'standard_price': 15, + 'list_price': 30, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-DELI4', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_timesheet5 = cls.env['product.product'].create({ + 'name': "Service delivered, create project only based on template", + 'standard_price': 17, + 'list_price': 34, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('uom.product_uom_hour').id, + 'uom_po_id': cls.env.ref('uom.product_uom_hour').id, + 'default_code': 'SERV-DELI5', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_template_id': cls.project_template.id, + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + + # -- milestones (delivered, manual) + cls.product_delivery_manual1 = cls.env['product.product'].create({ + 'name': "Service delivered, create no task", + 'standard_price': 11, + 'list_price': 13, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-DELI1', + 'service_type': 'manual', + 'service_tracking': 'no', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_manual2 = cls.env['product.product'].create({ + 'name': "Service delivered, create task in global project", + 'standard_price': 30, + 'list_price': 90, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-DELI2', + 'service_type': 'manual', + 'service_tracking': 'task_global_project', + 'project_id': cls.project_global.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_manual3 = cls.env['product.product'].create({ + 'name': "Service delivered, create task in new project", + 'standard_price': 10, + 'list_price': 20, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-DELI3', + 'service_type': 'manual', + 'service_tracking': 'task_in_project', + 'project_id': False, # will create a project + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_manual4 = cls.env['product.product'].create({ + 'name': "Service delivered, create project only", + 'standard_price': 15, + 'list_price': 30, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-DELI4', + 'service_type': 'manual', + 'service_tracking': 'project_only', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_manual5 = cls.env['product.product'].create({ + 'name': "Service delivered, create project only with template", + 'standard_price': 17, + 'list_price': 34, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('uom.product_uom_hour').id, + 'uom_po_id': cls.env.ref('uom.product_uom_hour').id, + 'default_code': 'SERV-DELI4', + 'service_type': 'manual', + 'service_tracking': 'project_only', + 'project_id': False, + 'project_template_id': cls.project_template.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) 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 00000000..cce8a216 --- /dev/null +++ b/addons/sale_timesheet/tests/test_project_billing.py @@ -0,0 +1,421 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestProjectBilling(TestCommonSaleTimesheet): + """ This test suite provide checks for miscellaneous small things. """ + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # set up + 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', + 'property_account_payable_id': cls.company_data['default_account_payable'].id, + 'property_account_receivable_id': cls.company_data['default_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_a.id, + 'partner_invoice_id': cls.partner_a.id, + 'partner_shipping_id': cls.partner_a.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, + '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) + + cls.project_employee_rate = Project.create({ + 'name': "Project billed at Employee Rate", + 'allow_timesheets': True, + 'allow_billable': True, + 'bill_type': 'customer_project', + 'pricing_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 """ + Timesheet = self.env['account.analytic.line'] + Task = self.env['project.task'] + # set a customer on the project + self.project_non_billable.write({ + 'partner_id': self.partner_2.id + }) + # create a task and 2 timesheets + task = Task.with_context(default_project_id=self.project_non_billable.id).create({ + 'name': 'first task', + 'partner_id': self.project_non_billable.partner_id.id, + 'planned_hours': 10, + }) + timesheet1 = Timesheet.create({ + 'name': 'Test Line', + 'project_id': task.project_id.id, + 'task_id': task.id, + 'unit_amount': 3, + 'employee_id': self.employee_manager.id, + }) + timesheet2 = Timesheet.create({ + 'name': 'Test Line tde', + 'project_id': task.project_id.id, + 'task_id': task.id, + 'unit_amount': 2, + 'employee_id': self.employee_tde.id, + }) + + # Change project to billable at task rate + self.project_non_billable.write({ + 'allow_billable': True, + 'bill_type': 'customer_project', + 'pricing_type': 'fixed_rate', + }) + task.timesheet_product_id = self.product_delivery_timesheet3 + + # create wizard + wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({}) + + self.assertEqual(wizard.partner_id, self.project_non_billable.partner_id, "The wizard should have the same partner as the project") + self.assertEqual(len(wizard.line_ids), 1, "The wizard should have one line") + self.assertEqual(wizard.line_ids.product_id, self.product_delivery_timesheet3, "The wizard should have one line with right product") + + # create the SO from the project + action = wizard.action_create_sale_order() + sale_order = self.env['sale.order'].browse(action['res_id']) + + 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") + self.assertEqual(sale_order.order_line.qty_delivered, timesheet1.unit_amount + timesheet2.unit_amount, "The create SOL should have an delivered quantity equals to the sum of tasks'timesheets") + + def test_make_billable_at_employee_rate(self): + """ Starting from a non billable project, make it billable at employee rate """ + Timesheet = self.env['account.analytic.line'] + Task = self.env['project.task'] + # set a customer on the project + self.project_non_billable.write({ + 'partner_id': self.partner_2.id + }) + # create a task and 2 timesheets + task = Task.with_context(default_project_id=self.project_non_billable.id).create({ + 'name': 'first task', + 'partner_id': self.project_non_billable.partner_id.id, + 'planned_hours': 10, + }) + timesheet1 = Timesheet.create({ + 'name': 'Test Line', + 'project_id': task.project_id.id, + 'task_id': task.id, + 'unit_amount': 3, + 'employee_id': self.employee_manager.id, + }) + timesheet2 = Timesheet.create({ + 'name': 'Test Line tde', + 'project_id': task.project_id.id, + 'task_id': task.id, + 'unit_amount': 2, + 'employee_id': self.employee_user.id, + }) + + # Change project to billable at employee rate + self.project_non_billable.write({ + 'allow_billable': True, + 'bill_type': 'customer_project', + 'pricing_type': 'employee_rate', + }) + + # create wizard + wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({ + 'partner_id': self.partner_2.id, + 'line_ids': [ + (0, 0, {'product_id': self.product_delivery_timesheet1.id, 'price_unit': 15, 'employee_id': self.employee_tde.id}), # product creates no T + (0, 0, {'product_id': self.product_delivery_timesheet1.id, 'price_unit': 15, 'employee_id': self.employee_manager.id}), # product creates no T (same product than previous one) + (0, 0, {'product_id': self.product_delivery_timesheet3.id, 'price_unit': self.product_delivery_timesheet3.list_price, 'employee_id': self.employee_user.id}), # product creates new T in new P + ] + }) + + 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(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), 2, "The SO should have 2 lines, as in wizard map there were 2 time the same product with the same price (for 2 different employees)") + self.assertEqual(len(self.project_non_billable.sale_line_employee_ids), 3, "The project have 3 lines in its map") + self.assertEqual(self.project_non_billable.sale_line_id, sale_order.order_line[0], "The wizard sets sale line fallbakc on project as the first of the list") + self.assertEqual(task.sale_line_id, sale_order.order_line[0], "The wizard sets sale line fallback on tasks") + self.assertEqual(task.partner_id, wizard.partner_id, "The wizard sets the customer on tasks to make SOL line field visible") + + 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_timesheet3) + + self.assertTrue(line1, "Sale line 1 with product 1 should exists") + self.assertTrue(line2, "Sale line 2 with product 3 should exists") + + self.assertFalse(line1.project_id, "Sale line 1 should be linked to the 'non billable' project") + self.assertEqual(line2.project_id, self.project_non_billable, "Sale line 3 should be linked to the 'non billable' project") + self.assertEqual(line1.price_unit, 15, "The unit price of SOL 1 should be 15") + self.assertEqual(line1.product_uom_qty, 3, "The ordered qty of SOL 1 should be 3") + self.assertEqual(line2.product_uom_qty, 2, "The ordered qty of SOL 2 should be 2") + + self.assertEqual(self.project_non_billable.sale_line_employee_ids.mapped('sale_line_id'), sale_order.order_line, "The SO lines of the map should be the same of the sales order") + self.assertEqual(timesheet1.so_line, line1, "Timesheet1 should be linked to sale line 1, as employee manager create the timesheet") + self.assertEqual(timesheet2.so_line, line2, "Timesheet2 should be linked to sale line 2, as employee tde create the timesheet") + self.assertEqual(timesheet1.unit_amount, line1.qty_delivered, "Sale line 1 should have a delivered qty equals to the sum of its linked timesheets") + self.assertEqual(timesheet2.unit_amount, line2.qty_delivered, "Sale line 2 should have a delivered qty equals to the sum of its linked timesheets") + + 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_a.id, + }) + + self.assertTrue(task.allow_billable, "Task in project 'employee rate' should be billable") + self.assertEqual(task.bill_type, 'customer_project', "Task in project 'employee rate' should be billed at employee rate") + 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.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.assertFalse(timesheet1.so_line, "The timesheet should be not linked to the project of the map entry since no SOL in the linked task.") + + task.write({ + 'sale_line_id': self.project_employee_rate_user.sale_line_id.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.assertFalse(subtask.allow_billable, "Subtask in non billable project should be non billable too") + self.assertFalse(subtask.project_id.allow_billable, "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.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.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.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") + + # create a second task in employee rate project + task2 = Task.with_context(default_project_id=self.project_employee_rate.id).create({ + 'name': 'first task', + 'partner_id': self.partner_a.id, + 'sale_line_id': False + }) + + # log timesheet on task in 'employee rate' project without any fallback (no map, no SOL on task, no SOL on project) + timesheet3 = Timesheet.create({ + 'name': 'Test Line', + 'project_id': task2.project_id.id, + 'task_id': task2.id, + 'unit_amount': 3, + 'employee_id': self.employee_tde.id, + }) + + self.assertFalse(timesheet3.so_line, "The timesheet should not be linked to SOL as there is no fallback at all (no map, no SOL on task, no SOL on project)") + + # log timesheet on task in 'employee rate' project (no map, no SOL on task, but SOL on project) + timesheet4 = Timesheet.create({ + 'name': 'Test Line ', + 'project_id': task2.project_id.id, + 'task_id': task2.id, + 'unit_amount': 4, + 'employee_id': self.employee_tde.id, + }) + + self.assertFalse(timesheet4.so_line, "The timesheet should not be linked to SOL, as no entry for TDE in project map") + + 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', + }) + 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") + + # 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.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.assertFalse(timesheet2.so_line, "The timesheet should not be linked to SOL as it's 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.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.assertFalse(subtask.sale_line_id, "Subask moved in a employee rate billable project have empty so line") + self.assertEqual(subtask.partner_id, task.project_id.partner_id, "Subask created in a project billed on 'employee rate' should have the same customer as the one from the project") diff --git a/addons/sale_timesheet/tests/test_project_billing_multicompany.py b/addons/sale_timesheet/tests/test_project_billing_multicompany.py new file mode 100644 index 00000000..ef4b8557 --- /dev/null +++ b/addons/sale_timesheet/tests/test_project_billing_multicompany.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet +from odoo.tests import tagged + + +@tagged('-at_install', 'post_install') +class TestProjectBillingMulticompany(TestCommonSaleTimesheet): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + Project = cls.env['project.project'].with_context(tracking_disable=True) + cls.project_non_billable = Project.create({ + 'name': "Non Billable Project", + 'allow_timesheets': True, + 'allow_billable': True, + 'bill_type': 'customer_project', + 'company_id': cls.env.company.id, + }) + + def test_makeBillable_multiCompany(self): + wizard = self.env['project.create.sale.order'].with_context(allowed_company_ids=[self.company_data_2['company'].id, self.env.company.id], company_id=self.company_data_2['company'].id, active_id=self.project_non_billable.id, active_model='project.project').create({ + 'line_ids': [(0, 0, { + 'product_id': self.product_delivery_timesheet3.id, # product creates new Timesheet in new Project + 'price_unit': self.product_delivery_timesheet3.list_price + })], + 'partner_id': self.partner_a.id, + }) + + action = wizard.action_create_sale_order() + sale_order = self.env['sale.order'].browse(action['res_id']) + + self.assertEqual(sale_order.company_id.id, self.project_non_billable.company_id.id, "The company on the sale order should be the same as the one on the project") diff --git a/addons/sale_timesheet/tests/test_project_overview.py b/addons/sale_timesheet/tests/test_project_overview.py new file mode 100644 index 00000000..90f51e1d --- /dev/null +++ b/addons/sale_timesheet/tests/test_project_overview.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.sale_timesheet.tests.test_reporting import TestReporting +from odoo.tools import float_compare +from odoo.tests import tagged + + +@tagged('-at_install', 'post_install') +class TestSaleProject(TestReporting): + + def test_project_overview_by_project(self): + rounding = self.env.company.currency_id.rounding + + so_line_deliver_global_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet2.name, + 'product_id': self.product_delivery_timesheet2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_delivery_timesheet2.uom_id.id, + 'price_unit': self.product_delivery_timesheet2.list_price, + 'order_id': self.sale_order_2.id, + }) + so_line_deliver_no_task = self.env['sale.order.line'].create({ + 'name': self.product_delivery_manual1.name, + 'product_id': self.product_delivery_manual1.id, + 'product_uom_qty': 50, + 'product_uom': self.product_delivery_manual1.uom_id.id, + 'price_unit': self.product_delivery_manual1.list_price, + 'order_id': self.sale_order_2.id, + }) + so_line_deliver_no_task.write({'qty_delivered': 1.0}) + + self.sale_order_2.action_confirm() + project_so = self.so_line_order_project.project_id + # log timesheet for billable time + timesheet1 = self._log_timesheet_manager(project_so, 10, so_line_deliver_global_project.task_id) + + task_so = self.so_line_order_project.task_id + # logged some timesheets: on project only, then on tasks with different employees + timesheet2 = self._log_timesheet_user(project_so, 2) + timesheet3 = self._log_timesheet_user(project_so, 3, task_so) + timesheet4 = self._log_timesheet_manager(project_so, 1, task_so) + + # create a task which is not linked to sales order and fill non-billable timesheet + task = self.env['project.task'].create({ + 'name': 'Task', + 'project_id': project_so.id, + 'allow_billable': False, + 'sale_line_id': False + }) + timesheet5 = self._log_timesheet_user(project_so, 5, task) + + # invoice the Sales Order SO2 + context = { + "active_model": 'sale.order', + "active_ids": [self.sale_order_2.id], + "active_id": self.sale_order_2.id, + 'open_invoices': True, + } + + payment = self.env['sale.advance.payment.inv'].create({ + 'advance_payment_method': 'delivered', + }) + + action_invoice = payment.with_context(context).create_invoices() + invoice = self.env['account.move'].browse(action_invoice['res_id']) + invoice.action_post() + + # simulate the auto creation of the SO line for expense, like we confirm a vendor bill. + so_line_expense = self.env['sale.order.line'].create({ + 'name': self.product_expense.name, + 'product_id': self.product_expense.id, + 'product_uom_qty': 0.0, + 'product_uom': self.product_expense.uom_id.id, + 'price_unit': self.product_expense.list_price, # reinvoice at sales price + 'order_id': self.sale_order_2.id, + 'is_expense': True, + }) + + expense = self.env['account.analytic.line'].create({ + 'name': 'expense on project_so', + 'account_id': project_so.analytic_account_id.id, + 'so_line': so_line_expense.id, + 'employee_id': self.employee_user.id, + 'unit_amount': 4, + 'amount': 4 * self.product_expense.list_price * -1, + 'product_id': self.product_expense.id, + 'product_uom_id': self.product_expense.uom_id.id, + }) + + other_revenues = self.env['account.analytic.line'].create({ + 'name': 'pther revenues on project_so', + 'account_id': project_so.analytic_account_id.id, + 'employee_id': self.employee_user.id, + 'unit_amount': 1, + 'amount': self.product_expense.list_price, + 'product_id': self.product_expense.id, + 'product_uom_id': self.product_expense.uom_id.id, + }) + + view_id = self.env.ref('sale_timesheet.project_timesheet_action_client_timesheet_plan').id + vals = self.env['project.project']._qweb_prepare_qcontext(view_id, [['id', '=', project_so.id]]) + + dashboard_value = timesheet2.unit_amount + timesheet3.unit_amount + timesheet4.unit_amount + timesheet5.unit_amount + timesheet1.unit_amount + project_so_timesheet_sold_unit = timesheet3.unit_amount + timesheet4.unit_amount + project_rate_non_billable = timesheet5.unit_amount / dashboard_value * 100 + project_rate_non_billable_project = timesheet2.unit_amount / dashboard_value * 100 + project_rate_billable_time = timesheet1.unit_amount / dashboard_value * 100 + project_rate_billable_fixed = project_so_timesheet_sold_unit / dashboard_value * 100 + project_rate_total = project_rate_non_billable + project_rate_non_billable_project + project_rate_billable_time + project_rate_billable_fixed + project_invoiced = self.so_line_order_project.price_unit * self.so_line_order_project.product_uom_qty * timesheet1.unit_amount + project_timesheet_cost = timesheet2.amount + timesheet3.amount + timesheet4.amount + timesheet5.amount + timesheet1.amount + project_other_revenues = invoice.invoice_line_ids.search([('product_id', '=', self.product_delivery_manual1.id)]) + + self.assertEqual(float_compare(vals['dashboard']['time']['non_billable'], timesheet5.unit_amount, precision_rounding=rounding), 0, "The hours non-billable should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['time']['non_billable_project'], timesheet2.unit_amount, precision_rounding=rounding), 0, "The hours non-billable-project should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['time']['billable_time'], timesheet1.unit_amount, precision_rounding=rounding), 0, "The hours billable-time should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['time']['billable_fixed'], project_so_timesheet_sold_unit, precision_rounding=rounding), 0, "The hours billable-fixed should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['time']['total'], dashboard_value, precision_rounding=rounding), 0, "The total hours should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['rates']['non_billable'], project_rate_non_billable, precision_rounding=rounding), 0, "The rate non-billable should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['rates']['non_billable_project'], project_rate_non_billable_project, precision_rounding=rounding), 0, "The rate non-billable-project should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['rates']['billable_time'], project_rate_billable_time, precision_rounding=rounding), 0, "The rate billable-time should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['rates']['billable_fixed'], project_rate_billable_fixed, precision_rounding=rounding), 0, "The rate billable-fixed should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['rates']['total'], project_rate_total, precision_rounding=rounding), 0, "The total rates should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['profit']['invoiced'], project_invoiced, precision_rounding=rounding), 0, "The amount invoiced should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['profit']['cost'], project_timesheet_cost, precision_rounding=rounding), 0, "The amount cost should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['profit']['expense_cost'], expense.amount, precision_rounding=rounding), 0, "The amount expense-cost should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['profit']['other_revenues'], other_revenues.amount + project_other_revenues.price_total, precision_rounding=rounding), 0, "The amount of the other revenues should be equal to the corresponding account move line and the one from the SO line") + self.assertEqual(float_compare(vals['dashboard']['profit']['total'], project_invoiced + project_timesheet_cost + expense.amount + other_revenues.amount + project_other_revenues.price_total, precision_rounding=rounding), 0, "The total amount should be the sum of the SO2 line and the created other_revenues account analytic line") + self.assertEqual(float_compare(vals['repartition_employee_max'], 11.0, precision_rounding=rounding), 0, "The amount of repartition-employee-max should be the one from SO2 line") diff --git a/addons/sale_timesheet/tests/test_reinvoice.py b/addons/sale_timesheet/tests/test_reinvoice.py new file mode 100644 index 00000000..0b85f450 --- /dev/null +++ b/addons/sale_timesheet/tests/test_reinvoice.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet +from odoo.tests import Form, tagged + + +@tagged('-at_install', 'post_install') +class TestReInvoice(TestCommonSaleTimesheet): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # patch expense products to make them services creating task/project + service_values = { + 'type': 'service', + 'service_type': 'timesheet', + 'service_tracking': 'task_in_project' + } + cls.company_data['product_order_cost'].write(service_values) + cls.company_data['product_delivery_cost'].write(service_values) + cls.company_data['product_order_sales_price'].write(service_values) + cls.company_data['product_delivery_sales_price'].write(service_values) + cls.company_data['product_order_no'].write(service_values) + + # create AA, SO and invoices + cls.analytic_account = cls.env['account.analytic.account'].create({ + 'name': 'Test AA', + 'code': 'TESTSALE_TIMESHEET_REINVOICE', + 'company_id': cls.company_data['company'].id, + 'partner_id': cls.partner_a.id + }) + + cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({ + 'partner_id': cls.partner_a.id, + 'partner_invoice_id': cls.partner_a.id, + 'partner_shipping_id': cls.partner_a.id, + 'analytic_account_id': cls.analytic_account.id, + 'pricelist_id': cls.company_data['default_pricelist'].id, + }) + + cls.Invoice = cls.env['account.move'].with_context( + default_move_type='in_invoice', + default_invoice_date=cls.sale_order.date_order, + mail_notrack=True, + mail_create_nolog=True, + ) + + def test_at_cost(self): + """ Test vendor bill at cost for product based on ordered and delivered quantities. """ + # create SO line and confirm SO (with only one line) + sale_order_line1 = self.env['sale.order.line'].create({ + 'name': self.company_data['product_order_cost'].name, + 'product_id': self.company_data['product_order_cost'].id, + 'product_uom_qty': 2, + 'product_uom': self.company_data['product_order_cost'].uom_id.id, + 'price_unit': self.company_data['product_order_cost'].list_price, + 'order_id': self.sale_order.id, + }) + sale_order_line1.product_id_change() + sale_order_line2 = self.env['sale.order.line'].create({ + 'name': self.company_data['product_delivery_cost'].name, + 'product_id': self.company_data['product_delivery_cost'].id, + 'product_uom_qty': 4, + 'product_uom': self.company_data['product_delivery_cost'].uom_id.id, + 'price_unit': self.company_data['product_delivery_cost'].list_price, + 'order_id': self.sale_order.id, + }) + sale_order_line2.product_id_change() + + self.sale_order.onchange_partner_id() + self.sale_order._compute_tax_id() + self.sale_order.action_confirm() + + self.assertEqual(sale_order_line1.qty_delivered_method, 'timesheet', "Delivered quantity of 'service' SO line should be computed by timesheet amount") + self.assertEqual(sale_order_line2.qty_delivered_method, 'timesheet', "Delivered quantity of 'service' SO line should be computed by timesheet amount") + + # let's log some timesheets (on the project created by sale_order_line1) + task_sol1 = sale_order_line1.task_id + self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_sol1.project_id.id, + 'task_id': task_sol1.id, + 'unit_amount': 1, + 'employee_id': self.employee_user.id, + 'company_id': self.company_data['company'].id, + }) + + move_form = Form(self.Invoice) + move_form.partner_id = self.partner_a + with move_form.line_ids.new() as line_form: + line_form.product_id = self.company_data['product_order_cost'] + line_form.quantity = 3.0 + line_form.analytic_account_id = self.analytic_account + with move_form.line_ids.new() as line_form: + line_form.product_id = self.company_data['product_delivery_cost'] + line_form.quantity = 3.0 + line_form.analytic_account_id = self.analytic_account + invoice_a = move_form.save() + invoice_a.action_post() + + sale_order_line3 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol.product_id == self.company_data['product_order_cost']) + sale_order_line4 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol.product_id == self.company_data['product_delivery_cost']) + + self.assertTrue(sale_order_line3, "A new sale line should have been created with ordered product") + self.assertTrue(sale_order_line4, "A new sale line should have been created with delivered product") + self.assertEqual(len(self.sale_order.order_line), 4, "There should be 4 lines on the SO (2 vendor bill lines created)") + self.assertEqual(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 2, "There should be 4 lines on the SO (2 vendor bill lines created)") + + self.assertEqual(sale_order_line1.qty_delivered, 1, "Exising SO line 1 should not be impacted by reinvoicing product at cost") + self.assertEqual(sale_order_line2.qty_delivered, 0, "Exising SO line 2 should not be impacted by reinvoicing product at cost") + + self.assertFalse(sale_order_line3.task_id, "Adding a new expense SO line should not create a task (sol3)") + self.assertFalse(sale_order_line4.task_id, "Adding a new expense SO line should not create a task (sol4)") + self.assertEqual(len(self.sale_order.project_ids), 1, "SO create only one project with its service line. Adding new expense SO line should not impact that") + + self.assertEqual((sale_order_line3.price_unit, sale_order_line3.qty_delivered, sale_order_line3.product_uom_qty, sale_order_line3.qty_invoiced), (self.company_data['product_order_cost'].standard_price, 3.0, 0, 0), 'Sale line is wrong after confirming vendor invoice') + self.assertEqual((sale_order_line4.price_unit, sale_order_line4.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line4.qty_invoiced), (self.company_data['product_delivery_cost'].standard_price, 3.0, 0, 0), 'Sale line is wrong after confirming vendor invoice') + + self.assertEqual(sale_order_line3.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line should be computed by analytic amount") + self.assertEqual(sale_order_line4.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line should be computed by analytic amount") + + # create second invoice lines and validate it + move_form = Form(self.Invoice) + move_form.partner_id = self.partner_a + with move_form.line_ids.new() as line_form: + line_form.product_id = self.company_data['product_order_cost'] + line_form.quantity = 2.0 + line_form.analytic_account_id = self.analytic_account + with move_form.line_ids.new() as line_form: + line_form.product_id = self.company_data['product_delivery_cost'] + line_form.quantity = 2.0 + line_form.analytic_account_id = self.analytic_account + invoice_b = move_form.save() + invoice_b.action_post() + + sale_order_line5 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol != sale_order_line3 and sol.product_id == self.company_data['product_order_cost']) + sale_order_line6 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol != sale_order_line4 and sol.product_id == self.company_data['product_delivery_cost']) + + self.assertTrue(sale_order_line5, "A new sale line should have been created with ordered product") + self.assertTrue(sale_order_line6, "A new sale line should have been created with delivered product") + + self.assertEqual(len(self.sale_order.order_line), 6, "There should be still 4 lines on the SO, no new created") + self.assertEqual(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 4, "There should be still 2 expenses lines on the SO") + + self.assertEqual((sale_order_line5.price_unit, sale_order_line5.qty_delivered, sale_order_line5.product_uom_qty, sale_order_line5.qty_invoiced), (self.company_data['product_order_cost'].standard_price, 2.0, 0, 0), 'Sale line 5 is wrong after confirming 2e vendor invoice') + self.assertEqual((sale_order_line6.price_unit, sale_order_line6.qty_delivered, sale_order_line6.product_uom_qty, sale_order_line6.qty_invoiced), (self.company_data['product_delivery_cost'].standard_price, 2.0, 0, 0), 'Sale line 6 is wrong after confirming 2e vendor invoice') + + def test_sales_price(self): + """ Test invoicing vendor bill at sales price for products based on delivered and ordered quantities. Check no existing SO line is incremented, but when invoicing a + second time, increment only the delivered so line. + """ + # create SO line and confirm SO (with only one line) + sale_order_line1 = self.env['sale.order.line'].create({ + 'name': self.company_data['product_delivery_sales_price'].name, + 'product_id': self.company_data['product_delivery_sales_price'].id, + 'product_uom_qty': 2, + 'qty_delivered': 1, + 'product_uom': self.company_data['product_delivery_sales_price'].uom_id.id, + 'price_unit': self.company_data['product_delivery_sales_price'].list_price, + 'order_id': self.sale_order.id, + }) + sale_order_line1.product_id_change() + sale_order_line2 = self.env['sale.order.line'].create({ + 'name': self.company_data['product_order_sales_price'].name, + 'product_id': self.company_data['product_order_sales_price'].id, + 'product_uom_qty': 3, + 'qty_delivered': 1, + 'product_uom': self.company_data['product_order_sales_price'].uom_id.id, + 'price_unit': self.company_data['product_order_sales_price'].list_price, + 'order_id': self.sale_order.id, + }) + sale_order_line2.product_id_change() + self.sale_order._compute_tax_id() + self.sale_order.action_confirm() + + # let's log some timesheets (on the project created by sale_order_line1) + task_sol1 = sale_order_line1.task_id + self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_sol1.project_id.id, + 'task_id': task_sol1.id, + 'unit_amount': 1, + 'employee_id': self.employee_user.id, + }) + + # create invoice lines and validate it + move_form = Form(self.Invoice) + move_form.partner_id = self.partner_a + with move_form.line_ids.new() as line_form: + line_form.product_id = self.company_data['product_delivery_sales_price'] + line_form.quantity = 3.0 + line_form.analytic_account_id = self.analytic_account + with move_form.line_ids.new() as line_form: + line_form.product_id = self.company_data['product_order_sales_price'] + line_form.quantity = 3.0 + line_form.analytic_account_id = self.analytic_account + invoice_a = move_form.save() + invoice_a.action_post() + + sale_order_line3 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol.product_id == self.company_data['product_delivery_sales_price']) + sale_order_line4 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol.product_id == self.company_data['product_order_sales_price']) + + self.assertTrue(sale_order_line3, "A new sale line should have been created with ordered product") + self.assertTrue(sale_order_line4, "A new sale line should have been created with delivered product") + self.assertEqual(len(self.sale_order.order_line), 4, "There should be 4 lines on the SO (2 vendor bill lines created)") + self.assertEqual(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 2, "There should be 4 lines on the SO (2 vendor bill lines created)") + + self.assertEqual(sale_order_line1.qty_delivered, 1, "Exising SO line 1 should not be impacted by reinvoicing product at cost") + self.assertEqual(sale_order_line2.qty_delivered, 0, "Exising SO line 2 should not be impacted by reinvoicing product at cost") + + self.assertFalse(sale_order_line3.task_id, "Adding a new expense SO line should not create a task (sol3)") + self.assertFalse(sale_order_line4.task_id, "Adding a new expense SO line should not create a task (sol4)") + self.assertEqual(len(self.sale_order.project_ids), 1, "SO create only one project with its service line. Adding new expense SO line should not impact that") + + self.assertEqual((sale_order_line3.price_unit, sale_order_line3.qty_delivered, sale_order_line3.product_uom_qty, sale_order_line3.qty_invoiced), (self.company_data['product_delivery_sales_price'].list_price, 3.0, 0, 0), 'Sale line is wrong after confirming vendor invoice') + self.assertEqual((sale_order_line4.price_unit, sale_order_line4.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line4.qty_invoiced), (self.company_data['product_order_sales_price'].list_price, 3.0, 0, 0), 'Sale line is wrong after confirming vendor invoice') + + self.assertEqual(sale_order_line3.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line 3 should be computed by analytic amount") + self.assertEqual(sale_order_line4.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line 4 should be computed by analytic amount") + + # create second invoice lines and validate it + move_form = Form(self.Invoice) + move_form.partner_id = self.partner_a + with move_form.line_ids.new() as line_form: + line_form.product_id = self.company_data['product_delivery_sales_price'] + line_form.quantity = 2.0 + line_form.analytic_account_id = self.analytic_account + with move_form.line_ids.new() as line_form: + line_form.product_id = self.company_data['product_order_sales_price'] + line_form.quantity = 2.0 + line_form.analytic_account_id = self.analytic_account + invoice_b = move_form.save() + invoice_b.action_post() + + sale_order_line5 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol != sale_order_line3 and sol.product_id == self.company_data['product_delivery_sales_price']) + sale_order_line6 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol != sale_order_line4 and sol.product_id == self.company_data['product_order_sales_price']) + + self.assertFalse(sale_order_line5, "No new sale line should have been created with delivered product !!") + self.assertTrue(sale_order_line6, "A new sale line should have been created with ordered product") + + self.assertEqual(len(self.sale_order.order_line), 5, "There should be 5 lines on the SO, 1 new created and 1 incremented") + self.assertEqual(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 3, "There should be 3 expenses lines on the SO") + + self.assertEqual((sale_order_line6.price_unit, sale_order_line6.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line6.qty_invoiced), (self.company_data['product_order_sales_price'].list_price, 2.0, 0, 0), 'Sale line is wrong after confirming 2e vendor invoice') + + def test_no_expense(self): + """ Test invoicing vendor bill with no policy. Check nothing happen. """ + # confirm SO + sale_order_line = self.env['sale.order.line'].create({ + 'name': self.company_data['product_order_no'].name, + 'product_id': self.company_data['product_order_no'].id, + 'product_uom_qty': 2, + 'qty_delivered': 1, + 'product_uom': self.company_data['product_order_no'].uom_id.id, + 'price_unit': self.company_data['product_order_no'].list_price, + 'order_id': self.sale_order.id, + }) + self.sale_order._compute_tax_id() + self.sale_order.action_confirm() + + # create invoice lines and validate it + move_form = Form(self.Invoice) + move_form.partner_id = self.partner_a + with move_form.line_ids.new() as line_form: + line_form.product_id = self.company_data['product_order_no'] + line_form.quantity = 3.0 + line_form.analytic_account_id = self.analytic_account + invoice_a = move_form.save() + invoice_a.action_post() + + # let's log some timesheets (on the project created by sale_order_line1) + task_sol1 = sale_order_line.task_id + self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_sol1.project_id.id, + 'task_id': task_sol1.id, + 'unit_amount': 1, + 'employee_id': self.employee_user.id, + }) + + self.assertEqual(len(self.sale_order.order_line), 1, "No SO line should have been created (or removed) when validating vendor bill") + self.assertEqual(sale_order_line.qty_delivered, 1, "The delivered quantity of SO line should not have been incremented") + self.assertTrue(invoice_a.mapped('line_ids.analytic_line_ids'), "Analytic lines should be generated") diff --git a/addons/sale_timesheet/tests/test_reporting.py b/addons/sale_timesheet/tests/test_reporting.py new file mode 100644 index 00000000..88d98cf9 --- /dev/null +++ b/addons/sale_timesheet/tests/test_reporting.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.tools import float_is_zero, float_compare +from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet +from odoo.tests import tagged + + +@tagged('-at_install', 'post_install') +class TestReporting(TestCommonSaleTimesheet): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # expense product + cls.product_expense = cls.env['product.product'].with_context(mail_notrack=True, mail_create_nolog=True).create({ + 'name': "Expense service", + 'standard_price': 10, + 'list_price': 20, + 'type': 'service', + 'invoice_policy': 'delivery', + 'expense_policy': 'sales_price', + 'default_code': 'EXP', + 'service_type': 'manual', + 'taxes_id': False, + 'property_account_income_id': cls.company_data['default_account_revenue'].id, + }) + + # create Analytic Accounts + cls.analytic_account_1 = cls.env['account.analytic.account'].create({ + 'name': 'Test AA 1', + 'code': 'AA1', + 'company_id': cls.company_data['company'].id, + 'partner_id': cls.partner_a.id + }) + cls.analytic_account_2 = cls.env['account.analytic.account'].create({ + 'name': 'Test AA 2', + 'code': 'AA2', + 'company_id': cls.company_data['company'].id, + 'partner_id': cls.partner_a.id + }) + + # Sale orders each will create project and a task in a global project (one SO is 'delivered', the other is 'ordered') + cls.sale_order_1 = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({ + 'partner_id': cls.partner_a.id, + 'partner_invoice_id': cls.partner_a.id, + 'partner_shipping_id': cls.partner_a.id, + 'analytic_account_id': cls.analytic_account_1.id, + }) + cls.so_line_deliver_project = cls.env['sale.order.line'].create({ + '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, + 'order_id': cls.sale_order_1.id, + }) + cls.so_line_deliver_task = cls.env['sale.order.line'].create({ + 'name': cls.product_delivery_timesheet2.name, + 'product_id': cls.product_delivery_timesheet2.id, + 'product_uom_qty': 7, + 'product_uom': cls.product_delivery_timesheet2.uom_id.id, + 'price_unit': cls.product_delivery_timesheet2.list_price, + 'order_id': cls.sale_order_1.id, + }) + + cls.sale_order_2 = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({ + 'partner_id': cls.partner_a.id, + 'partner_invoice_id': cls.partner_a.id, + 'partner_shipping_id': cls.partner_a.id, + 'analytic_account_id': cls.analytic_account_2.id, + }) + cls.so_line_order_project = cls.env['sale.order.line'].create({ + 'name': cls.product_order_timesheet3.name, + 'product_id': cls.product_order_timesheet3.id, + 'product_uom_qty': 5, + 'product_uom': cls.product_order_timesheet3.uom_id.id, + 'price_unit': cls.product_order_timesheet3.list_price, + 'order_id': cls.sale_order_2.id, + }) + cls.so_line_order_task = cls.env['sale.order.line'].create({ + 'name': cls.product_order_timesheet2.name, + 'product_id': cls.product_order_timesheet2.id, + 'product_uom_qty': 7, + 'product_uom': cls.product_order_timesheet2.uom_id.id, + 'price_unit': cls.product_order_timesheet2.list_price, + 'order_id': cls.sale_order_2.id, + }) + + def _log_timesheet_user(self, project, unit_amount, task=False): + """ Utility method to log timesheet """ + Timesheet = self.env['account.analytic.line'] + return Timesheet.create({ + 'name': 'timesheet employee on project_so_1 only', + 'account_id': project.analytic_account_id.id, + 'project_id': project.id, + 'employee_id': self.employee_user.id, + 'unit_amount': unit_amount, + 'task_id': task.id if task else False, + }) + + def _log_timesheet_manager(self, project, unit_amount, task=False): + """ Utility method to log timesheet """ + Timesheet = self.env['account.analytic.line'] + return Timesheet.create({ + 'name': 'timesheet employee on project_so_1 only', + 'account_id': project.analytic_account_id.id, + 'project_id': project.id, + 'employee_id': self.employee_manager.id, + 'unit_amount': unit_amount, + 'task_id': task.id if task else False, + }) + + def test_profitability_report(self): + + # this test suppose everything is in the same currency as the current one + currency = self.env.company.currency_id + rounding = currency.rounding + + project_global_stat = self.env['project.profitability.report'].search([('project_id', '=', self.project_global.id)]).read()[0] + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['timesheet_unit_amount'], precision_rounding=rounding), "The timesheet unit amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['timesheet_cost'], precision_rounding=rounding), "The timesheet cost of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + # confirm sales orders + self.sale_order_1.action_confirm() + self.sale_order_2.action_confirm() + self.env['project.profitability.report'].flush() + + project_so_1 = self.so_line_deliver_project.project_id + project_so_2 = self.so_line_order_project.project_id + task_so_1 = self.so_line_deliver_project.task_id + task_so_2 = self.so_line_order_project.task_id + task_in_global_1 = self.so_line_deliver_task.task_id + task_in_global_2 = self.so_line_order_task.task_id + + # deliver project should not be impacted, as no timesheet are logged yet + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['timesheet_unit_amount'], precision_rounding=rounding), "The timesheet unit amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['timesheet_cost'], precision_rounding=rounding), "The timesheet cost of the global project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO1 should be 0.0") + + # order project should be to invoice, but nothing has been delivered yet + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_to_invoice'], self.so_line_order_project.price_unit * self.so_line_order_project.qty_to_invoice, precision_rounding=rounding), 0, "The amount to invoice should be the one from the SO line, as we are in ordered quantity") + self.assertTrue(float_is_zero(project_so_2_stat['timesheet_unit_amount'], precision_rounding=rounding), "The timesheet unit amount of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['timesheet_cost'], precision_rounding=rounding), "The timesheet cost of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project now contain 2 tasks: 'deliver' one which has no effect (no timesheet yet), and the 'order' one which should update the 'to invoice' amount + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the global project should be 0.0") + self.assertEqual(float_compare(project_global_stat['amount_untaxed_to_invoice'], self.so_line_order_task.price_unit * self.so_line_order_task.qty_to_invoice, precision_rounding=rounding), 0, "The amount to invoice of global project should take the task in 'oredered qty' into account") + self.assertTrue(float_is_zero(project_global_stat['timesheet_unit_amount'], precision_rounding=rounding), "The timesheet unit amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['timesheet_cost'], precision_rounding=rounding), "The timesheet cost of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + # logged some timesheets: on project only, then on tasks with different employees + timesheet1 = self._log_timesheet_user(project_so_1, 2) + timesheet2 = self._log_timesheet_user(project_so_2, 2) + timesheet3 = self._log_timesheet_user(project_so_1, 3, task_so_1) + timesheet4 = self._log_timesheet_user(project_so_2, 3, task_so_2) + + timesheet5 = self._log_timesheet_manager(project_so_1, 1, task_so_1) + timesheet6 = self._log_timesheet_manager(project_so_2, 1, task_so_2) + timesheet7 = self._log_timesheet_manager(self.project_global, 3, task_in_global_1) + timesheet8 = self._log_timesheet_manager(self.project_global, 3, task_in_global_2) + self.env['project.profitability.report'].flush() + + # deliver project should now have cost and something to invoice + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_1_timesheet_cost = timesheet1.amount + timesheet3.amount + timesheet5.amount + project_so_1_timesheet_sold_unit = timesheet1.unit_amount + timesheet3.unit_amount + timesheet5.unit_amount + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['amount_untaxed_to_invoice'], self.so_line_deliver_project.price_unit * project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The amount to invoice of the project from SO1 should only include timesheet linked to task") + self.assertEqual(float_compare(project_so_1_stat['timesheet_unit_amount'], project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO1 should include all timesheet in project") + self.assertEqual(float_compare(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO1 should include all timesheet") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO1 should be 0.0") + + # order project still have something to invoice but has costs now + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_2_timesheet_cost = timesheet2.amount + timesheet4.amount + timesheet6.amount + project_so_2_timesheet_sold_unit = timesheet2.unit_amount + timesheet4.unit_amount + timesheet6.unit_amount + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_to_invoice'], self.so_line_order_project.price_unit * self.so_line_order_project.qty_to_invoice, precision_rounding=rounding), 0, "The amount to invoice should be the one from the SO line, as we are in ordered quantity") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO2 should include all timesheet") + self.assertEqual(float_compare(project_so_2_stat['timesheet_cost'], project_so_2_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO2 should include all timesheet") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project should have more to invoice, and should now have costs + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_global_timesheet_cost = timesheet7.amount + timesheet8.amount + project_global_timesheet_unit = timesheet7.unit_amount + timesheet8.unit_amount + project_global_to_invoice = (self.so_line_order_task.price_unit * self.so_line_order_task.product_uom_qty) + (self.so_line_deliver_task.price_unit * timesheet7.unit_amount) + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the global project should be 0.0") + self.assertEqual(float_compare(project_global_stat['amount_untaxed_to_invoice'], project_global_to_invoice, precision_rounding=rounding), 0, "The amount to invoice of global project should take the task in 'oredered qty' and the delivered timesheets into account") + self.assertEqual(float_compare(project_global_stat['timesheet_unit_amount'], project_global_timesheet_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the global project should include all timesheet") + self.assertEqual(float_compare(project_global_stat['timesheet_cost'], project_global_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the global project should include all timesheet") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + InvoiceWizard = self.env['sale.advance.payment.inv'].with_context(mail_notrack=True) + + # invoice the Sales Order SO1 (based on delivered qty) + context = { + "active_model": 'sale.order', + "active_ids": [self.sale_order_1.id], + "active_id": self.sale_order_1.id, + 'open_invoices': True, + } + payment = InvoiceWizard.create({ + 'advance_payment_method': 'delivered', + }) + action_invoice = payment.with_context(context).create_invoices() + invoice_id = action_invoice['res_id'] + invoice_1 = self.env['account.move'].browse(invoice_id) + invoice_1.action_post() + self.env['project.profitability.report'].flush() + + # deliver project should now have cost and something invoiced + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_1_timesheet_cost = timesheet1.amount + timesheet3.amount + timesheet5.amount + project_so_1_timesheet_sold_unit = timesheet1.unit_amount + timesheet3.unit_amount + timesheet5.unit_amount + + self.assertEqual(float_compare(project_so_1_stat['amount_untaxed_invoiced'], self.so_line_deliver_project.price_unit * project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The invoiced amount of the project from SO1 should only include timesheet linked to task") + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['timesheet_unit_amount'], project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO1 should include all timesheet in project") + self.assertEqual(float_compare(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO1 should include all timesheet") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO1 should be 0.0") + + # order project has still nothing invoiced + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_2_timesheet_cost = timesheet2.amount + timesheet4.amount + timesheet6.amount + project_so_2_timesheet_sold_unit = timesheet2.unit_amount + timesheet4.unit_amount + timesheet6.unit_amount + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_to_invoice'], self.so_line_order_project.price_unit * self.so_line_order_project.qty_to_invoice, precision_rounding=rounding), 0, "The amount to invoice should be the one from the SO line, as we are in ordered quantity") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO2 should include all timesheet") + self.assertEqual(float_compare(project_so_2_stat['timesheet_cost'], project_so_2_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO2 should include all timesheet") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project should have more to invoice, and should now have costs + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_global_timesheet_cost = timesheet7.amount + timesheet8.amount + project_global_timesheet_unit = timesheet7.unit_amount + timesheet8.unit_amount + project_global_to_invoice = self.so_line_order_task.price_unit * self.so_line_order_task.product_uom_qty + project_global_invoiced = self.so_line_deliver_task.price_unit * timesheet7.unit_amount + self.assertEqual(float_compare(project_global_stat['amount_untaxed_invoiced'], project_global_invoiced, precision_rounding=rounding), 0, "The invoiced amount of the global project should be 0.0") + self.assertEqual(float_compare(project_global_stat['amount_untaxed_to_invoice'], project_global_to_invoice, precision_rounding=rounding), 0, "The amount to invoice of global project should take the task in 'oredered qty' and the delivered timesheets into account") + self.assertEqual(float_compare(project_global_stat['timesheet_unit_amount'], project_global_timesheet_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the global project should include all timesheet") + self.assertEqual(float_compare(project_global_stat['timesheet_cost'], project_global_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the global project should include all timesheet") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + # invoice the Sales Order SO2 + context = { + "active_model": 'sale.order', + "active_ids": [self.sale_order_2.id], + "active_id": self.sale_order_2.id, + 'open_invoices': True, + } + payment = InvoiceWizard.create({ + 'advance_payment_method': 'delivered', + }) + action_invoice = payment.with_context(context).create_invoices() + invoice_id = action_invoice['res_id'] + invoice_2 = self.env['account.move'].browse(invoice_id) + invoice_2.action_post() + self.env['project.profitability.report'].flush() + + # deliver project should not be impacted by the invoice of the other SO + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_1_timesheet_cost = timesheet1.amount + timesheet3.amount + timesheet5.amount + project_so_1_timesheet_sold_unit = timesheet1.unit_amount + timesheet3.unit_amount + timesheet5.unit_amount + self.assertEqual(float_compare(project_so_1_stat['amount_untaxed_invoiced'], self.so_line_deliver_project.price_unit * project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The invoiced amount of the project from SO1 should only include timesheet linked to task") + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['timesheet_unit_amount'], project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO1 should include all timesheet in project") + self.assertEqual(float_compare(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO1 should include all timesheet") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO1 should be 0.0") + + # order project is now totally invoiced, as we are in ordered qty + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_2_timesheet_cost = timesheet2.amount + timesheet4.amount + timesheet6.amount + project_so_2_timesheet_sold_unit = timesheet2.unit_amount + timesheet4.unit_amount + timesheet6.unit_amount + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_invoiced'], self.so_line_order_project.price_unit * self.so_line_order_project.product_uom_qty, precision_rounding=rounding), 0, "The invoiced amount should be the one from the SO line, as we are in ordered quantity") + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice should be the one 0.0, as all ordered quantity is invoiced") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO2 should include all timesheet") + self.assertEqual(float_compare(project_so_2_stat['timesheet_cost'], project_so_2_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO2 should include all timesheet") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project should be totally invoiced + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_global_timesheet_cost = timesheet7.amount + timesheet8.amount + project_global_timesheet_unit = timesheet7.unit_amount + timesheet8.unit_amount + project_global_invoiced = self.so_line_order_task.price_unit * self.so_line_order_task.product_uom_qty + self.so_line_deliver_task.price_unit * timesheet7.unit_amount + self.assertEqual(float_compare(project_global_stat['amount_untaxed_invoiced'], project_global_invoiced, precision_rounding=rounding), 0, "The invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of global project should take the task in 'oredered qty' and the delivered timesheets into account") + self.assertEqual(float_compare(project_global_stat['timesheet_unit_amount'], project_global_timesheet_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the global project should include all timesheet") + self.assertEqual(float_compare(project_global_stat['timesheet_cost'], project_global_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the global project should include all timesheet") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + # simulate the auto creation of the SO line for expense, like we confirm a vendor bill. + so_line_expense = self.env['sale.order.line'].create({ + 'name': self.product_expense.name, + 'product_id': self.product_expense.id, + 'product_uom_qty': 0.0, + 'product_uom': self.product_expense.uom_id.id, + 'price_unit': self.product_expense.list_price, # reinvoice at sales price + 'order_id': self.sale_order_1.id, + 'is_expense': True, + }) + + # add expense AAL: 20% margin when reinvoicing + AnalyticLine = self.env['account.analytic.line'] + expense1 = AnalyticLine.create({ + 'name': 'expense on project_so_1', + 'account_id': project_so_1.analytic_account_id.id, + 'so_line': so_line_expense.id, + 'employee_id': self.employee_user.id, + 'unit_amount': 4, + 'amount': 4 * self.product_expense.list_price * -1, + 'product_id': self.product_expense.id, + 'product_uom_id': self.product_expense.uom_id.id, + }) + expense2 = AnalyticLine.create({ + 'name': 'expense on global project', + 'account_id': self.project_global.analytic_account_id.id, + 'employee_id': self.employee_user.id, + 'unit_amount': 2, + 'amount': 2 * self.product_expense.list_price * -1, + 'product_id': self.product_expense.id, + 'product_uom_id': self.product_expense.uom_id.id, + }) + self.env['project.profitability.report'].flush() + + # deliver project should now have expense cost, and expense to reinvoice as there is a still open sales order linked to the AA1 + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_1_timesheet_cost = timesheet1.amount + timesheet3.amount + timesheet5.amount + project_so_1_timesheet_sold_unit = timesheet1.unit_amount + timesheet3.unit_amount + timesheet5.unit_amount + self.assertEqual(float_compare(project_so_1_stat['amount_untaxed_invoiced'], self.so_line_deliver_project.price_unit * project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The invoiced amount of the project from SO1 should only include timesheet linked to task") + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['timesheet_unit_amount'], project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO1 should include all timesheet in project") + self.assertEqual(float_compare(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO1 should include all timesheet") + self.assertEqual(float_compare(project_so_1_stat['expense_amount_untaxed_to_invoice'], -1 * expense1.amount, precision_rounding=rounding), 0, "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['expense_cost'], expense1.amount, precision_rounding=rounding), 0, "The expense cost of the project from SO1 should be 0.0") + + # order project is not impacted by the expenses + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_2_timesheet_cost = timesheet2.amount + timesheet4.amount + timesheet6.amount + project_so_2_timesheet_sold_unit = timesheet2.unit_amount + timesheet4.unit_amount + timesheet6.unit_amount + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_invoiced'], self.so_line_order_project.price_unit * self.so_line_order_project.product_uom_qty, precision_rounding=rounding), 0, "The invoiced amount should be the one from the SO line, as we are in ordered quantity") + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice should be the one 0.0, as all ordered quantity is invoiced") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO2 should include all timesheet") + self.assertEqual(float_compare(project_so_2_stat['timesheet_cost'], project_so_2_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO2 should include all timesheet") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project should have an expense, but not reinvoiceable + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_global_timesheet_cost = timesheet7.amount + timesheet8.amount + project_global_timesheet_unit = timesheet7.unit_amount + timesheet8.unit_amount + project_global_invoiced = self.so_line_order_task.price_unit * self.so_line_order_task.product_uom_qty + self.so_line_deliver_task.price_unit * timesheet7.unit_amount + self.assertEqual(float_compare(project_global_stat['amount_untaxed_invoiced'], project_global_invoiced, precision_rounding=rounding), 0, "The invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of global project should take the task in 'oredered qty' and the delivered timesheets into account") + self.assertEqual(float_compare(project_global_stat['timesheet_unit_amount'], project_global_timesheet_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the global project should include all timesheet") + self.assertEqual(float_compare(project_global_stat['timesheet_cost'], project_global_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the global project should include all timesheet") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_global_stat['expense_cost'], expense2.amount, precision_rounding=rounding), 0, "The expense cost of the global project should be 0.0") diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py new file mode 100644 index 00000000..ca487559 --- /dev/null +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -0,0 +1,599 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet +from odoo.exceptions import UserError, ValidationError +from odoo.tests import tagged + + +@tagged('-at_install', 'post_install') +class TestSaleService(TestCommonSaleTimesheet): + """ This test suite provide checks for miscellaneous small things. """ + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({ + 'partner_id': cls.partner_a.id, + 'partner_invoice_id': cls.partner_a.id, + 'partner_shipping_id': cls.partner_a.id, + }) + + def test_sale_service(self): + """ Test task creation when confirming a sale_order with the corresponding product """ + sale_order_line = self.env['sale.order.line'].create({ + 'order_id': self.sale_order.id, + 'name': self.product_delivery_timesheet2.name, + 'product_id': self.product_delivery_timesheet2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_delivery_timesheet2.uom_id.id, + 'price_unit': self.product_delivery_timesheet2.list_price + }) + + self.sale_order.order_line._compute_product_updatable() + self.assertTrue(sale_order_line.product_updatable) + self.sale_order.action_confirm() + self.sale_order.order_line._compute_product_updatable() + + self.sale_order.action_confirm() + self.sale_order.order_line._compute_product_updatable() + self.assertFalse(sale_order_line.product_updatable) + self.assertEqual(self.sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation') + + # check task creation + project = self.project_global + task = project.task_ids.filtered(lambda t: t.name == '%s: %s' % (self.sale_order.name, self.product_delivery_timesheet2.name)) + self.assertTrue(task, 'Sale Service: task is not created, or it badly named') + self.assertEqual(task.partner_id, self.sale_order.partner_id, 'Sale Service: customer should be the same on task and on SO') + self.assertEqual(task.email_from, self.sale_order.partner_id.email, 'Sale Service: Task Email should be the same as the SO customer Email') + + # log timesheet on task + self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': project.id, + 'task_id': task.id, + 'unit_amount': 50, + 'employee_id': self.employee_manager.id, + }) + self.assertEqual(self.sale_order.invoice_status, 'to invoice', 'Sale Service: there should be sale_ordermething to invoice after registering timesheets') + self.sale_order._create_invoices() + + self.assertTrue(sale_order_line.product_uom_qty == sale_order_line.qty_delivered == sale_order_line.qty_invoiced, 'Sale Service: line should be invoiced completely') + self.assertEqual(self.sale_order.invoice_status, 'invoiced', 'Sale Service: SO should be invoiced') + self.assertEqual(self.sale_order.tasks_count, 1, "A task should have been created on SO confirmation.") + + # Add a line on the confirmed SO, and it should generate a new task directly + product_service_task = self.env['product.product'].create({ + 'name': "Delivered Service", + 'standard_price': 30, + 'list_price': 90, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': self.env.ref('uom.product_uom_hour').id, + 'uom_po_id': self.env.ref('uom.product_uom_hour').id, + 'default_code': 'SERV-DELI', + 'service_type': 'timesheet', + 'service_tracking': 'task_global_project', + 'project_id': project.id + }) + + self.env['sale.order.line'].create({ + 'name': product_service_task.name, + 'product_id': product_service_task.id, + 'product_uom_qty': 10, + 'product_uom': product_service_task.uom_id.id, + 'price_unit': product_service_task.list_price, + 'order_id': self.sale_order.id, + }) + + self.assertEqual(self.sale_order.tasks_count, 2, "Adding a new service line on a confirmer SO should create a new task.") + + # not possible to delete a task linked to a SOL + with self.assertRaises(ValidationError): + task.unlink() + + def test_timesheet_uom(self): + """ Test timesheet invoicing and uom conversion """ + # create SO and confirm it + uom_days = self.env.ref('uom.product_uom_day') + sale_order_line = self.env['sale.order.line'].create({ + 'order_id': self.sale_order.id, + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 5, + 'product_uom': uom_days.id, + 'price_unit': self.product_delivery_timesheet3.list_price + }) + self.sale_order.action_confirm() + task = self.env['project.task'].search([('sale_line_id', '=', sale_order_line.id)]) + + # let's log some timesheets + self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task.project_id.id, + 'task_id': task.id, + 'unit_amount': 16, + 'employee_id': self.employee_manager.id, + }) + self.assertEqual(sale_order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong') + + self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task.project_id.id, + 'task_id': task.id, + 'unit_amount': 24, + 'employee_id': self.employee_user.id, + }) + self.sale_order._create_invoices() + self.assertEqual(self.sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so') + + def test_task_so_line_assignation(self): + # create SO line and confirm it + so_line_deliver_global_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet2.name, + 'product_id': self.product_delivery_timesheet2.id, + 'product_uom_qty': 10, + 'product_uom': self.product_delivery_timesheet2.uom_id.id, + 'price_unit': self.product_delivery_timesheet2.list_price, + 'order_id': self.sale_order.id, + }) + so_line_deliver_global_project.product_id_change() + self.sale_order.action_confirm() + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) + + # let's log some timesheets (on the project created by so_line_ordered_project_only) + timesheets = self.env['account.analytic.line'] + timesheets |= self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 4, + 'employee_id': self.employee_user.id, + }) + timesheets |= self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 1, + 'employee_id': self.employee_manager.id, + }) + self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "All timesheets linked to the task should be on 'billable time'") + self.assertEqual(so_line_deliver_global_project.qty_to_invoice, 5, "Quantity to invoice should have been increased when logging timesheet on delivered quantities task") + + # invoice SO, and validate invoice + invoice = self.sale_order._create_invoices()[0] + invoice.action_post() + + # make task non billable + task_serv2.write({'sale_line_id': False}) + 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 invoiced timesheet are not modified when changing the task SO line") + + # try to update timesheets, catch error 'You cannot modify invoiced timesheet' + with self.assertRaises(UserError): + timesheets.write({'so_line': False}) + + def test_delivered_quantity(self): + # create SO line and confirm it + so_line_deliver_new_task_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 10, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + so_line_deliver_new_task_project.product_id_change() + self.sale_order.action_confirm() + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_new_task_project.id)]) + + # add a timesheet + timesheet1 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 4, + 'employee_id': self.employee_user.id, + }) + self.assertEqual(so_line_deliver_new_task_project.qty_delivered, timesheet1.unit_amount, 'Delivered quantity should be the same then its only related timesheet.') + + # remove the only timesheet + timesheet1.unlink() + self.assertEqual(so_line_deliver_new_task_project.qty_delivered, 0.0, 'Delivered quantity should be reset to zero, since there is no more timesheet.') + + # log 2 new timesheets + timesheet2 = self.env['account.analytic.line'].create({ + 'name': 'Test Line 2', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 4, + 'employee_id': self.employee_user.id, + }) + timesheet3 = self.env['account.analytic.line'].create({ + 'name': 'Test Line 3', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 2, + 'employee_id': self.employee_user.id, + }) + self.assertEqual(so_line_deliver_new_task_project.qty_delivered, timesheet2.unit_amount + timesheet3.unit_amount, 'Delivered quantity should be the sum of the 2 timesheets unit amounts.') + + # remove timesheet2 + timesheet2.unlink() + self.assertEqual(so_line_deliver_new_task_project.qty_delivered, timesheet3.unit_amount, 'Delivered quantity should be reset to the sum of remaining timesheets unit amounts.') + + def test_sale_create_task(self): + """ Check that confirming SO create correctly a task, and reconfirming it does not create a second one. Also check changing + the ordered quantity of a SO line that have created a task should update the planned hours of this task. + """ + so_line1 = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 7, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + + # confirm SO + self.sale_order.action_confirm() + + self.assertTrue(so_line1.task_id, "SO confirmation should create a task and link it to SOL") + self.assertTrue(so_line1.project_id, "SO confirmation should create a project and link it to SOL") + self.assertEqual(self.sale_order.tasks_count, 1, "The SO should have only one task") + self.assertEqual(so_line1.task_id.sale_line_id, so_line1, "The created task is also linked to its origin sale line, for invoicing purpose.") + self.assertFalse(so_line1.task_id.user_id, "The created task should be unassigned") + self.assertEqual(so_line1.product_uom_qty, so_line1.task_id.planned_hours, "The planned hours should be the same as the ordered quantity of the native SO line") + + so_line1.write({'product_uom_qty': 20}) + self.assertEqual(so_line1.product_uom_qty, so_line1.task_id.planned_hours, "The planned hours should have changed when updating the ordered quantity of the native SO line") + + # cancel SO + self.sale_order.action_cancel() + + self.assertTrue(so_line1.task_id, "SO cancellation should keep the task") + self.assertTrue(so_line1.project_id, "SO cancellation should create a project") + self.assertEqual(self.sale_order.tasks_count, 1, "The SO should still have only one task") + self.assertEqual(so_line1.task_id.sale_line_id, so_line1, "The created task is also linked to its origin sale line, for invoicing purpose.") + + so_line1.write({'product_uom_qty': 30}) + self.assertEqual(so_line1.product_uom_qty, so_line1.task_id.planned_hours, "The planned hours should have changed when updating the ordered quantity, even after SO cancellation") + + # reconfirm SO + self.sale_order.action_draft() + self.sale_order.action_confirm() + + self.assertTrue(so_line1.task_id, "SO reconfirmation should not have create another task") + self.assertTrue(so_line1.project_id, "SO reconfirmation should bit have create another project") + self.assertEqual(self.sale_order.tasks_count, 1, "The SO should still have only one task") + self.assertEqual(so_line1.task_id.sale_line_id, so_line1, "The created task is also linked to its origin sale line, for invoicing purpose.") + + self.sale_order.action_done() + with self.assertRaises(UserError): + so_line1.write({'product_uom_qty': 20}) + + def test_sale_create_project(self): + """ A SO with multiple product that should create project (with and without template) like ; + Line 1 : Service 1 create project with Template A ===> project created with template A + Line 2 : Service 2 create project no template ==> empty project created + Line 3 : Service 3 create project with Template A ===> Don't create any project because line 1 has already created a project with template A + Line 4 : Service 4 create project no template ==> Don't create any project because line 2 has already created an empty project + Line 5 : Service 5 create project with Template B ===> project created with template B + """ + # second project template and its associated product + project_template2 = self.env['project.project'].create({ + 'name': 'Second Project TEMPLATE for services', + 'allow_timesheets': True, + 'active': False, # this template is archived + }) + Stage = self.env['project.task.type'].with_context(default_project_id=project_template2.id) + stage1_tmpl2 = Stage.create({ + 'name': 'Stage 1', + 'sequence': 1 + }) + stage2_tmpl2 = Stage.create({ + 'name': 'Stage 2', + 'sequence': 2 + }) + product_deli_ts_tmpl = self.env['product.product'].create({ + 'name': "Service delivered, create project only based on template B", + 'standard_price': 17, + 'list_price': 34, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': self.env.ref('uom.product_uom_hour').id, + 'uom_po_id': self.env.ref('uom.product_uom_hour').id, + 'default_code': 'SERV-DELI4', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_template_id': project_template2.id, + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': self.account_sale.id, + }) + + # create 5 so lines + so_line1 = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet5.name, + 'product_id': self.product_delivery_timesheet5.id, + 'product_uom_qty': 11, + 'product_uom': self.product_delivery_timesheet5.uom_id.id, + 'price_unit': self.product_delivery_timesheet5.list_price, + 'order_id': self.sale_order.id, + }) + so_line2 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet4.name, + 'product_id': self.product_order_timesheet4.id, + 'product_uom_qty': 10, + 'product_uom': self.product_order_timesheet4.uom_id.id, + 'price_unit': self.product_order_timesheet4.list_price, + 'order_id': self.sale_order.id, + }) + so_line3 = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet5.name, + 'product_id': self.product_delivery_timesheet5.id, + 'product_uom_qty': 5, + 'product_uom': self.product_delivery_timesheet5.uom_id.id, + 'price_unit': self.product_delivery_timesheet5.list_price, + 'order_id': self.sale_order.id, + }) + so_line4 = self.env['sale.order.line'].create({ + 'name': self.product_delivery_manual3.name, + 'product_id': self.product_delivery_manual3.id, + 'product_uom_qty': 4, + 'product_uom': self.product_delivery_manual3.uom_id.id, + 'price_unit': self.product_delivery_manual3.list_price, + 'order_id': self.sale_order.id, + }) + so_line5 = self.env['sale.order.line'].create({ + 'name': product_deli_ts_tmpl.name, + 'product_id': product_deli_ts_tmpl.id, + 'product_uom_qty': 8, + 'product_uom': product_deli_ts_tmpl.uom_id.id, + 'price_unit': product_deli_ts_tmpl.list_price, + 'order_id': self.sale_order.id, + }) + + # confirm SO + self.sale_order.action_confirm() + + # check each line has or no generate something + self.assertTrue(so_line1.project_id, "Line1 should have create a project based on template A") + self.assertTrue(so_line2.project_id, "Line2 should have create an empty project") + self.assertEqual(so_line3.project_id, so_line1.project_id, "Line3 should reuse project of line1") + self.assertEqual(so_line4.project_id, so_line2.project_id, "Line4 should reuse project of line2") + self.assertTrue(so_line4.task_id, "Line4 should have create a new task, even if no project created.") + self.assertTrue(so_line5.project_id, "Line5 should have create a project based on template B") + + # check all generated project should be active, even if the template is not + self.assertTrue(so_line1.project_id.active, "Project of Line1 should be active") + self.assertTrue(so_line2.project_id.active, "Project of Line2 should be active") + self.assertTrue(so_line5.project_id.active, "Project of Line5 should be active") + + # check generated stuff are correct + self.assertTrue(so_line1.project_id in self.project_template_state.project_ids, "Stage 1 from template B should be part of project from so line 1") + self.assertTrue(so_line1.project_id in self.project_template_state.project_ids, "Stage 1 from template B should be part of project from so line 1") + + self.assertTrue(so_line5.project_id in stage1_tmpl2.project_ids, "Stage 1 from template B should be part of project from so line 5") + self.assertTrue(so_line5.project_id in stage2_tmpl2.project_ids, "Stage 2 from template B should be part of project from so line 5") + + self.assertTrue(so_line1.project_id.allow_timesheets, "Create project should allow timesheets") + self.assertTrue(so_line2.project_id.allow_timesheets, "Create project should allow timesheets") + self.assertTrue(so_line5.project_id.allow_timesheets, "Create project should allow timesheets") + + self.assertEqual(so_line4.task_id.project_id, so_line2.project_id, "Task created with line 4 should have the project based on template A of the SO.") + + self.assertEqual(so_line1.project_id.sale_line_id, so_line1, "SO line of project with template A should be the one that create it.") + self.assertEqual(so_line2.project_id.sale_line_id, so_line2, "SO line of project should be the one that create it.") + self.assertEqual(so_line5.project_id.sale_line_id, so_line5, "SO line of project with template B should be the one that create it.") + + def test_sale_task_in_project_with_project(self): + """ This will test the new 'task_in_project' service tracking correctly creates tasks and projects + when a project_id is configured on the parent sale_order (ref task #1915660). + + Setup: + - Configure a project_id on the SO + - SO line 1: a product with its delivery tracking set to 'task_in_project' + - SO line 2: the same product as SO line 1 + - SO line 3: a product with its delivery tracking set to 'project_only' + - Confirm sale_order + + Expected result: + - 2 tasks created on the project_id configured on the SO + - 1 project created with the correct template for the 'project_only' product + """ + + self.sale_order.write({'project_id': self.project_global.id}) + self.sale_order._onchange_project_id() + self.assertEqual(self.sale_order.analytic_account_id, self.analytic_account_sale, "Changing the project on the SO should set the analytic account accordingly.") + + so_line1 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet3.name, + 'product_id': self.product_order_timesheet3.id, + 'product_uom_qty': 11, + 'product_uom': self.product_order_timesheet3.uom_id.id, + 'price_unit': self.product_order_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + so_line2 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet3.name, + 'product_id': self.product_order_timesheet3.id, + 'product_uom_qty': 10, + 'product_uom': self.product_order_timesheet3.uom_id.id, + 'price_unit': self.product_order_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + so_line3 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet4.name, + 'product_id': self.product_order_timesheet4.id, + 'product_uom_qty': 5, + 'product_uom': self.product_order_timesheet4.uom_id.id, + 'price_unit': self.product_order_timesheet4.list_price, + 'order_id': self.sale_order.id, + }) + + # temporary project_template_id for our checks + self.product_order_timesheet4.write({ + 'project_template_id': self.project_template.id + }) + self.sale_order.action_confirm() + # remove it after the confirm because other tests don't like it + self.product_order_timesheet4.write({ + 'project_template_id': False + }) + + self.assertTrue(so_line1.task_id, "so_line1 should create a task as its product's service_tracking is set as 'task_in_project'") + self.assertEqual(so_line1.task_id.project_id, self.project_global, "The project on so_line1's task should be project_global as configured on its parent sale_order") + self.assertTrue(so_line2.task_id, "so_line2 should create a task as its product's service_tracking is set as 'task_in_project'") + self.assertEqual(so_line2.task_id.project_id, self.project_global, "The project on so_line2's task should be project_global as configured on its parent sale_order") + self.assertFalse(so_line3.task_id.name, "so_line3 should not create a task as its product's service_tracking is set as 'project_only'") + self.assertNotEqual(so_line3.project_id, self.project_template, "so_line3 should create a new project and not directly use the configured template") + self.assertIn(self.project_template.name, so_line3.project_id.name, "The created project for so_line3 should use the configured template") + + def test_sale_task_in_project_without_project(self): + """ This will test the new 'task_in_project' service tracking correctly creates tasks and projects + when the parent sale_order does NOT have a configured project_id (ref task #1915660). + + Setup: + - SO line 1: a product with its delivery tracking set to 'task_in_project' + - Confirm sale_order + + Expected result: + - 1 project created with the correct template for the 'task_in_project' because the SO + does not have a configured project_id + - 1 task created from this new project + """ + + so_line1 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet3.name, + 'product_id': self.product_order_timesheet3.id, + 'product_uom_qty': 10, + 'product_uom': self.product_order_timesheet3.uom_id.id, + 'price_unit': self.product_order_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + + # temporary project_template_id for our checks + self.product_order_timesheet3.write({ + 'project_template_id': self.project_template.id + }) + self.sale_order.action_confirm() + # remove it after the confirm because other tests don't like it + self.product_order_timesheet3.write({ + 'project_template_id': False + }) + + self.assertTrue(so_line1.task_id, "so_line1 should create a task as its product's service_tracking is set as 'task_in_project'") + self.assertNotEqual(so_line1.project_id, self.project_template, "so_line1 should create a new project and not directly use the configured template") + self.assertIn(self.project_template.name, so_line1.project_id.name, "The created project for so_line1 should use the configured template") + + def test_billable_task_and_subtask(self): + """ Test if subtasks and tasks are billed on the correct SO line """ + # create SO line and confirm it + so_line_deliver_new_task_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 10, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + so_line_deliver_new_task_project_2 = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet3.name + "(2)", + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 10, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + so_line_deliver_new_task_project.product_id_change() + so_line_deliver_new_task_project_2.product_id_change() + self.sale_order.action_confirm() + + project = so_line_deliver_new_task_project.project_id + task = so_line_deliver_new_task_project.task_id + + self.assertEqual(project.sale_line_id, so_line_deliver_new_task_project, "The created project should be linked to the so line") + self.assertEqual(task.sale_line_id, so_line_deliver_new_task_project, "The created task should be linked to the so line") + + # create a new task and subtask + subtask = self.env['project.task'].create({ + 'parent_id': task.id, + 'project_id': project.id, + 'name': '%s: substask1' % (task.name,), + }) + task2 = self.env['project.task'].create({ + 'project_id': project.id, + 'name': '%s: substask1' % (task.name,) + }) + + self.assertEqual(subtask.sale_line_id, task.sale_line_id, "By, default, a child task should have the same SO line as its mother") + self.assertEqual(task2.sale_line_id, project.sale_line_id, "A new task in a billable project should have the same SO line as its project") + self.assertEqual(task2.partner_id, so_line_deliver_new_task_project.order_partner_id, "A new task in a billable project should have the same SO line as its project") + + # moving subtask in another project + subtask.write({'project_id': self.project_global.id}) + + self.assertEqual(subtask.sale_line_id, task.sale_line_id, "A child task should always have the same SO line as its mother, even when changing project") + self.assertEqual(subtask.sale_line_id, so_line_deliver_new_task_project) + + # changing the SO line of the mother task + task.write({'sale_line_id': so_line_deliver_new_task_project_2.id}) + + self.assertEqual(subtask.sale_line_id, so_line_deliver_new_task_project, "A child task is not impacted by the change of SO line of its mother") + self.assertEqual(task.sale_line_id, so_line_deliver_new_task_project_2, "A mother task can have its SO line set manually") + + # changing the SO line of a subtask + subtask.write({'sale_line_id': so_line_deliver_new_task_project_2.id}) + + self.assertEqual(subtask.sale_line_id, so_line_deliver_new_task_project_2, "A child can have its SO line set manually") + + def test_change_ordered_qty(self): + """ Changing the ordered quantity of a SO line that have created a task should update the planned hours of this task """ + sale_order_line = self.env['sale.order.line'].create({ + 'order_id': self.sale_order.id, + 'name': self.product_delivery_timesheet2.name, + 'product_id': self.product_delivery_timesheet2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_delivery_timesheet2.uom_id.id, + 'price_unit': self.product_delivery_timesheet2.list_price + }) + + self.sale_order.action_confirm() + self.assertEqual(sale_order_line.product_uom_qty, sale_order_line.task_id.planned_hours, "The planned hours should be the same as the ordered quantity of the native SO line") + + sale_order_line.write({'product_uom_qty': 20}) + self.assertEqual(sale_order_line.product_uom_qty, sale_order_line.task_id.planned_hours, "The planned hours should have changed when updating the ordered quantity of the native SO line") + + self.sale_order.action_cancel() + sale_order_line.write({'product_uom_qty': 30}) + self.assertEqual(sale_order_line.product_uom_qty, sale_order_line.task_id.planned_hours, "The planned hours should have changed when updating the ordered quantity, even after SO cancellation") + + self.sale_order.action_done() + with self.assertRaises(UserError): + sale_order_line.write({'product_uom_qty': 20}) + + def test_copy_billable_project_and_task(self): + sale_order_line = self.env['sale.order.line'].create({ + 'order_id': self.sale_order.id, + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 5, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price + }) + self.sale_order.action_confirm() + task = self.env['project.task'].search([('sale_line_id', '=', sale_order_line.id)]) + project = sale_order_line.project_id + + # copy the project + project_copy = project.copy() + self.assertFalse(project_copy.sale_line_id, "Duplicating project should erase its Sale line") + self.assertFalse(project_copy.sale_order_id, "Duplicating project should erase its Sale order") + self.assertEqual(len(project.tasks), len(project_copy.tasks), "Copied project must have the same number of tasks") + self.assertFalse(project_copy.tasks.mapped('sale_line_id'), "The tasks of the duplicated project should not have a Sale Line set.") + + # 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") diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py new file mode 100644 index 00000000..9e3411e1 --- /dev/null +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -0,0 +1,601 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import date, timedelta + +from odoo.fields import Date +from odoo.tools import float_is_zero +from odoo.exceptions import UserError +from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet +from odoo.tests import tagged + + +@tagged('-at_install', 'post_install') +class TestSaleTimesheet(TestCommonSaleTimesheet): + """ This test suite provide tests for the 3 main flows of selling services: + - Selling services based on ordered quantities + - Selling timesheet based on delivered quantities + - Selling milestones, based on manual delivered quantities + For that, we check the task/project created, the invoiced amounts, the delivered + quantities changes, ... + """ + + def test_timesheet_order(self): + """ Test timesheet invoicing with 'invoice on order' timetracked products + 1. create SO with 2 ordered product and confirm + 2. create invoice + 3. log timesheet + 4. add new SO line (ordered service) + 5. create new invoice + """ + # create SO and confirm it + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'partner_invoice_id': self.partner_a.id, + 'partner_shipping_id': self.partner_a.id, + 'pricelist_id': self.company_data['default_pricelist'].id, + }) + so_line_ordered_project_only = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet4.name, + 'product_id': self.product_order_timesheet4.id, + 'product_uom_qty': 10, + 'product_uom': self.product_order_timesheet4.uom_id.id, + 'price_unit': self.product_order_timesheet4.list_price, + 'order_id': sale_order.id, + }) + so_line_ordered_global_project = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet2.name, + 'product_id': self.product_order_timesheet2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_order_timesheet2.uom_id.id, + 'price_unit': self.product_order_timesheet2.list_price, + 'order_id': sale_order.id, + }) + so_line_ordered_project_only.product_id_change() + so_line_ordered_global_project.product_id_change() + sale_order.action_confirm() + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_global_project.id)]) + project_serv1 = self.env['project.project'].search([('sale_line_id', '=', so_line_ordered_project_only.id)]) + + self.assertEqual(sale_order.tasks_count, 1, "One task should have been created on SO confirmation") + self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 2 'task in global project'") + self.assertEqual(sale_order.analytic_account_id, project_serv1.analytic_account_id, "The created project should be linked to the analytic account of the SO") + + # create invoice + invoice1 = sale_order._create_invoices()[0] + + # let's log some timesheets (on the project created by so_line_ordered_project_only) + timesheet1 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 10.5, + 'employee_id': self.employee_user.id, + }) + self.assertEqual(so_line_ordered_global_project.qty_delivered, 10.5, 'Timesheet directly on project does not increase delivered quantity on so line') + self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') + self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Timesheets linked to SO line with ordered product shoulbe be billable fixed") + self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, since we are in ordered quantity") + + timesheet2 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 39.5, + 'employee_id': self.employee_user.id, + }) + self.assertEqual(so_line_ordered_global_project.qty_delivered, 50, 'Sale Timesheet: timesheet does not increase delivered quantity on so line') + self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') + self.assertEqual(timesheet2.timesheet_invoice_type, 'billable_fixed', "Timesheets linked to SO line with ordered product shoulbe be billable fixed") + self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet should not be linked to the invoice, since we are in ordered quantity") + + timesheet3 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv2.project_id.id, + 'unit_amount': 10, + 'employee_id': self.employee_user.id, + }) + self.assertEqual(so_line_ordered_project_only.qty_delivered, 0.0, 'Timesheet directly on project does not increase delivered quantity on so line') + self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'") + self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet should not be linked to the invoice, since we are in ordered quantity") + + # log timesheet on task in global project (higher than the initial ordrered qty) + timesheet4 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 5, + 'employee_id': self.employee_user.id, + }) + self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') + self.assertFalse(timesheet4.timesheet_invoice_id, "The timesheet should not be linked to the invoice, since we are in ordered quantity") + + # add so line with produdct "create task in new project". + so_line_ordered_task_in_project = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet3.name, + 'product_id': self.product_order_timesheet3.id, + 'product_uom_qty': 3, + 'product_uom': self.product_order_timesheet3.uom_id.id, + 'price_unit': self.product_order_timesheet3.list_price, + 'order_id': sale_order.id, + }) + + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: Adding a new service line (so line) should put the SO in "to invocie" state.') + self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation") + self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'new task in new project' product, since it reuse the one from 'project only'.") + + # get first invoice line of sale line linked to timesheet1 + invoice_line_1 = so_line_ordered_global_project.invoice_lines.filtered(lambda line: line.move_id == invoice1) + + self.assertEqual(so_line_ordered_global_project.product_uom_qty, invoice_line_1.quantity, "The invoice (ordered) quantity should not change when creating timesheet") + + # timesheet can be modified + timesheet1.write({'unit_amount': 12}) + + self.assertEqual(so_line_ordered_global_project.product_uom_qty, invoice_line_1.quantity, "The invoice (ordered) quantity should not change when modifying timesheet") + + # create second invoice + invoice2 = sale_order._create_invoices()[0] + + self.assertEqual(len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO") + self.assertTrue(float_is_zero(invoice2.amount_total - so_line_ordered_task_in_project.price_unit * 3, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong') + + self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, since we are in ordered quantity") + self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice, since we are in ordered quantity") + self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet3 should not be linked to the invoice, since we are in ordered quantity") + self.assertFalse(timesheet4.timesheet_invoice_id, "The timesheet4 should not be linked to the invoice, since we are in ordered quantity") + + # validate the first invoice + invoice1.action_post() + + self.assertEqual(so_line_ordered_global_project.product_uom_qty, invoice_line_1.quantity, "The invoice (ordered) quantity should not change when modifying timesheet") + self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, since we are in ordered quantity") + self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice, since we are in ordered quantity") + self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet3 should not be linked to the invoice, since we are in ordered quantity") + self.assertFalse(timesheet4.timesheet_invoice_id, "The timesheet4 should not be linked to the invoice, since we are in ordered quantity") + + # timesheet can still be modified + timesheet1.write({'unit_amount': 13}) + + def test_timesheet_delivery(self): + """ Test timesheet invoicing with 'invoice on delivery' timetracked products + 1. Create SO and confirm it + 2. log timesheet + 3. create invoice + 4. log other timesheet + 5. create a second invoice + 6. add new SO line (delivered service) + """ + # create SO and confirm it + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'partner_invoice_id': self.partner_a.id, + 'partner_shipping_id': self.partner_a.id, + 'pricelist_id': self.company_data['default_pricelist'].id, + }) + so_line_deliver_global_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet2.name, + 'product_id': self.product_delivery_timesheet2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_delivery_timesheet2.uom_id.id, + 'price_unit': self.product_delivery_timesheet2.list_price, + 'order_id': sale_order.id, + }) + so_line_deliver_task_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 20, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price, + 'order_id': sale_order.id, + }) + so_line_deliver_global_project.product_id_change() + so_line_deliver_task_project.product_id_change() + + # confirm SO + sale_order.action_confirm() + task_serv1 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + + self.assertEqual(task_serv1.project_id, self.project_global, "Sale Timesheet: task should be created in global project") + self.assertTrue(task_serv1, "Sale Timesheet: on SO confirmation, a task should have been created in global project") + self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in a new project") + self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation') + self.assertEqual(sale_order.analytic_account_id, task_serv2.project_id.analytic_account_id, "SO should have create a project") + self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation") + self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 1 'task in global project'") + self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO") + + # let's log some timesheets + timesheet1 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv1.project_id.id, # global project + 'task_id': task_serv1.id, + 'unit_amount': 10.5, + 'employee_id': self.employee_manager.id, + }) + self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged') + self.assertEqual(so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line') + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so in "to invoice" status when logged') + self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_time', "Timesheets linked to SO line with delivered product shoulbe be billable time") + self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice yet") + + # invoice SO + invoice1 = sale_order._create_invoices() + self.assertTrue(float_is_zero(invoice1.amount_total - so_line_deliver_global_project.price_unit * 10.5, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong') + self.assertEqual(timesheet1.timesheet_invoice_id, invoice1, "The timesheet1 should not be linked to the invoice 1, as we are in delivered quantity (even if invoice is in draft") + with self.assertRaises(UserError): # We can not modify timesheet linked to invoice (even draft ones) + timesheet1.write({'unit_amount': 42}) + + # log some timesheets again + timesheet2 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv1.project_id.id, # global project + 'task_id': task_serv1.id, + 'unit_amount': 39.5, + 'employee_id': self.employee_user.id, + }) + self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged') + self.assertEqual(so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line') + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so') + self.assertEqual(timesheet2.timesheet_invoice_type, 'billable_time', "Timesheets linked to SO line with delivered product shoulbe be billable time") + self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice yet") + + # create a second invoice + invoice2 = sale_order._create_invoices()[0] + self.assertEqual(len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO") + self.assertEqual(so_line_deliver_global_project.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged') + self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now') + self.assertEqual(timesheet2.timesheet_invoice_id, invoice2, "The timesheet2 should not be linked to the invoice 2") + with self.assertRaises(UserError): # We can not modify timesheet linked to invoice (even draft ones) + timesheet2.write({'unit_amount': 42}) + + # add a line on SO + so_line_deliver_only_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet4.name, + 'product_id': self.product_delivery_timesheet4.id, + 'product_uom_qty': 5, + 'product_uom': self.product_delivery_timesheet4.uom_id.id, + 'price_unit': self.product_delivery_timesheet4.list_price, + 'order_id': sale_order.id, + }) + self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'project only' product, since it reuse the one from 'new task in new project'.") + + # let's log some timesheets on the project + timesheet3 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': project_serv2.id, + 'unit_amount': 7, + 'employee_id': self.employee_user.id, + }) + self.assertTrue(float_is_zero(so_line_deliver_only_project.qty_delivered, precision_digits=2), "Timesheeting on project should not incremented the delivered quantity on the SO line") + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should have quantity to invoice') + self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'") + self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet3 should not be linked to the invoice yet") + + # let's log some timesheets on the task (new task/new project) + timesheet4 = self.env['account.analytic.line'].create({ + 'name': 'Test Line 4', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 7, + 'employee_id': self.employee_user.id, + }) + self.assertFalse(timesheet4.timesheet_invoice_id, "The timesheet4 should not be linked to the invoice yet") + + # modify a non invoiced timesheet + timesheet4.write({'unit_amount': 42}) + + self.assertFalse(timesheet4.timesheet_invoice_id, "The timesheet4 should not still be linked to the invoice") + + # validate the second invoice + invoice2.action_post() + + self.assertEqual(timesheet1.timesheet_invoice_id, invoice1, "The timesheet1 should not be linked to the invoice 1, even after validation") + self.assertEqual(timesheet2.timesheet_invoice_id, invoice2, "The timesheet2 should not be linked to the invoice 1, even after validation") + self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet3 should not be linked to the invoice, since we are in ordered quantity") + self.assertFalse(timesheet4.timesheet_invoice_id, "The timesheet4 should not be linked to the invoice, since we are in ordered quantity") + + def test_timesheet_manual(self): + """ Test timesheet invoicing with 'invoice on delivery' timetracked products + """ + # create SO and confirm it + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'partner_invoice_id': self.partner_a.id, + 'partner_shipping_id': self.partner_a.id, + 'pricelist_id': self.company_data['default_pricelist'].id, + }) + so_line_manual_global_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_manual2.name, + 'product_id': self.product_delivery_manual2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_delivery_manual2.uom_id.id, + 'price_unit': self.product_delivery_manual2.list_price, + 'order_id': sale_order.id, + }) + so_line_manual_only_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_manual4.name, + 'product_id': self.product_delivery_manual4.id, + 'product_uom_qty': 20, + 'product_uom': self.product_delivery_manual4.uom_id.id, + 'price_unit': self.product_delivery_manual4.list_price, + 'order_id': sale_order.id, + }) + + # confirm SO + sale_order.action_confirm() + self.assertTrue(sale_order.project_ids, "Sales Order should have create a project") + self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: manually product should not need to be invoiced on so confirmation') + + project_serv2 = so_line_manual_only_project.project_id + self.assertTrue(project_serv2, "A second project is created when selling 'project only' after SO confirmation.") + self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO") + + # let's log some timesheets (on task and project) + timesheet1 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': self.project_global.id, # global project + 'task_id': so_line_manual_global_project.task_id.id, + 'unit_amount': 6, + 'employee_id': self.employee_manager.id, + }) + + timesheet2 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': self.project_global.id, # global project + 'unit_amount': 3, + 'employee_id': self.employee_manager.id, + }) + + self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one coming from SO line 1 'task in global project'.") + self.assertEqual(so_line_manual_global_project.task_id.sale_line_id, so_line_manual_global_project, "Task from a milestone product should be linked to its SO line too") + self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Milestone timesheet goes in billable fixed category") + self.assertTrue(float_is_zero(so_line_manual_global_project.qty_delivered, precision_digits=2), "Milestone Timesheeting should not incremented the delivered quantity on the SO line") + self.assertEqual(so_line_manual_global_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created task.") + self.assertEqual(so_line_manual_only_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created project.") + self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation') + + self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Timesheets linked to SO line with ordered product shoulbe be billable fixed since it is a milestone") + self.assertEqual(timesheet2.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'") + self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice") + self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice") + + # invoice SO + sale_order.order_line.write({'qty_delivered': 5}) + invoice1 = sale_order._create_invoices() + + for invoice_line in invoice1.invoice_line_ids: + self.assertEqual(invoice_line.quantity, 5, "The invoiced quantity should be 5, as manually set on SO lines") + + self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, since timesheets are used for time tracking in milestone") + self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice, since timesheets are used for time tracking in milestone") + + # validate the invoice + invoice1.action_post() + + self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, even after invoice validation") + self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice, even after invoice validation") + + def test_timesheet_invoice(self): + """ Test to create invoices for the sale order with timesheets + + 1) create sale order + 2) try to create an invoice for the timesheets 10 days before + 3) create invoice for the timesheets 6 days before + 4) create invoice for the timesheets 4 days before + 5) create invoice for the timesheets from today + """ + today = Date.context_today(self.env.user) + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'partner_invoice_id': self.partner_a.id, + 'partner_shipping_id': self.partner_a.id, + 'pricelist_id': self.company_data['default_pricelist'].id, + }) + # Section Line + so_line_ordered_project_only = self.env['sale.order.line'].create({ + 'name': "Section Name", + 'order_id': sale_order.id, + 'display_type': 'line_section', + }) + so_line_deliver_global_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet2.name, + 'product_id': self.product_delivery_timesheet2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_delivery_timesheet2.uom_id.id, + 'price_unit': self.product_delivery_timesheet2.list_price, + 'order_id': sale_order.id, + }) + so_line_deliver_task_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 20, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price, + 'order_id': sale_order.id, + }) + so_line_deliver_global_project.product_id_change() + so_line_deliver_task_project.product_id_change() + + # confirm SO + sale_order.action_confirm() + task_serv1 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + + timesheet1 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv1.project_id.id, + 'task_id': task_serv1.id, + 'unit_amount': 10, + 'employee_id': self.employee_manager.id, + 'date': today - timedelta(days=6) + }) + + timesheet2 = self.env['account.analytic.line'].create({ + 'name': 'Test Line 2', + 'project_id': task_serv1.project_id.id, + 'task_id': task_serv1.id, + 'unit_amount': 20, + 'employee_id': self.employee_manager.id, + 'date': today - timedelta(days=1) + }) + + timesheet3 = self.env['account.analytic.line'].create({ + 'name': 'Test Line 3', + 'project_id': task_serv1.project_id.id, + 'task_id': task_serv1.id, + 'unit_amount': 10, + 'employee_id': self.employee_manager.id, + 'date': today - timedelta(days=5) + }) + + timesheet4 = self.env['account.analytic.line'].create({ + 'name': 'Test Line 4', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 30, + 'employee_id': self.employee_manager.id + }) + self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice') + self.assertEqual(so_line_deliver_task_project.invoice_status, 'to invoice') + self.assertEqual(sale_order.invoice_status, 'to invoice') + + # Context for sale.advance.payment.inv wizard + 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 + } + + # invoice SO + wizard = self.env['sale.advance.payment.inv'].with_context(self.context).create({ + 'advance_payment_method': 'delivered', + 'date_start_invoice_timesheet': today - timedelta(days=16), + 'date_end_invoice_timesheet': today - timedelta(days=10) + }) + + self.assertTrue(wizard.invoicing_timesheet_enabled, 'The "date_start_invoice_timesheet" and "date_end_invoice_timesheet" field should be visible in the wizard because a product in sale order has service_policy to "Timesheet on Task"') + + with self.assertRaises(UserError): + wizard.create_invoices() + + self.assertFalse(sale_order.invoice_ids, 'Normally, no invoice will be created because the timesheet logged is after the period defined in date_start_invoice_timesheet and date_end_invoice_timesheet field') + + wizard.write({ + 'date_start_invoice_timesheet': today - timedelta(days=10), + 'date_end_invoice_timesheet': today - timedelta(days=6) + }) + wizard.create_invoices() + + self.assertTrue(sale_order.invoice_ids, 'One invoice should be created because the timesheet logged is between the period defined in wizard') + + invoice = sale_order.invoice_ids[0] + self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount) + + # validate invoice + invoice.action_post() + + wizard.write({ + 'date_start_invoice_timesheet': today - timedelta(days=16), + 'date_end_invoice_timesheet': today - timedelta(days=4) + }) + wizard.create_invoices() + + self.assertEqual(len(sale_order.invoice_ids), 2) + invoice2 = sale_order.invoice_ids[-1] + + self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount + timesheet3.unit_amount, "The last invoice done should have the quantity of the timesheet 3, because the date this timesheet is the only one before the 'date_end_invoice_timesheet' field in the wizard.") + + wizard.write({ + 'date_start_invoice_timesheet': today - timedelta(days=4), + 'date_end_invoice_timesheet': today + }) + + wizard.create_invoices() + + self.assertEqual(len(sale_order.invoice_ids), 3) + invoice3 = sale_order.invoice_ids[-1] + + # Check if all timesheets have been invoiced + 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_a.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.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 + }) + + 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") |
