summaryrefslogtreecommitdiff
path: root/addons/sale_timesheet/tests
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/sale_timesheet/tests
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/sale_timesheet/tests')
-rw-r--r--addons/sale_timesheet/tests/__init__.py11
-rw-r--r--addons/sale_timesheet/tests/common.py321
-rw-r--r--addons/sale_timesheet/tests/test_project_billing.py421
-rw-r--r--addons/sale_timesheet/tests/test_project_billing_multicompany.py36
-rw-r--r--addons/sale_timesheet/tests/test_project_overview.py130
-rw-r--r--addons/sale_timesheet/tests/test_reinvoice.py285
-rw-r--r--addons/sale_timesheet/tests/test_reporting.py399
-rw-r--r--addons/sale_timesheet/tests/test_sale_service.py599
-rw-r--r--addons/sale_timesheet/tests/test_sale_timesheet.py601
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")