Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,18 @@
# # -*- 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
from . import test_project_billing
from . import test_project_profitability
from . import test_reinvoice
from . import test_project_billing_multicompany
from . import test_upsell_warning
from . import test_edit_so_line_timesheet
from . import test_so_line_determined_in_timesheet
from . import test_sale_timesheet_ui
from . import test_project_pricing_type
from . import test_project_update
from . import test_sale_timesheet_accrued_entries

View file

@ -0,0 +1,283 @@
# -*- 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_project.tests.common import TestSaleProjectCommon
class TestCommonSaleTimesheet(TestSaleProjectCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.env.company.resource_calendar_id.tz = "Europe/Brussels"
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',
'hourly_cost': 15,
})
cls.employee_manager = cls.env['hr.employee'].create({
'name': 'Employee Manager',
'hourly_cost': 45,
})
cls.employee_company_B = cls.env['hr.employee'].create({
'name': 'Gregor Clegane',
'user_id': cls.user_employee_company_B.id,
'hourly_cost': 15,
})
cls.manager_company_B = cls.env['hr.employee'].create({
'name': 'Cersei Lannister',
'user_id': cls.user_manager_company_B.id,
'hourly_cost': 45,
})
# Account and project
cls.analytic_account_sale.name = 'Project for selling timesheet - AA'
cls.analytic_plan = cls.env['account.analytic.plan'].create({
'name': 'Plan Test',
'company_id': cls.company_data_2['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',
'plan_id': cls.analytic_plan.id,
'company_id': cls.company_data_2['company'].id,
})
# Create projects
Project = cls.env['project.project'].with_context(tracking_disable=True)
cls.project_global.write({
'name': 'Project for selling timesheets',
'allow_timesheets': True,
})
cls.project_template.write({
'name': 'Project TEMPLATE for services',
})
# Projects: at least one per billable type
cls.project_task_rate = Project.create({
'name': 'Project with pricing_type="task_rate"',
'allow_timesheets': True,
'allow_billable': True,
'partner_id': cls.partner_b.id,
'analytic_account_id': cls.analytic_account_sale.id,
})
cls.project_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,
})
# Create service products
# -- 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': cls.uom_hour.id,
'uom_po_id': cls.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': cls.uom_hour.id,
'uom_po_id': cls.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': cls.uom_hour.id,
'uom_po_id': cls.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': cls.uom_hour.id,
'uom_po_id': cls.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.uom_hour.id,
'uom_po_id': cls.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': cls.uom_hour.id,
'uom_po_id': cls.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': cls.uom_hour.id,
'uom_po_id': cls.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': cls.uom_hour.id,
'uom_po_id': cls.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': cls.uom_hour.id,
'uom_po_id': cls.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.uom_hour.id,
'uom_po_id': cls.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,
})
def setUp(self):
super().setUp()
self.so = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
'partner_id': self.partner_b.id,
'partner_invoice_id': self.partner_b.id,
'partner_shipping_id': self.partner_b.id,
})
self.env['sale.order.line'].create([{
'order_id': self.so.id,
'product_id': self.product_delivery_timesheet1.id,
'product_uom_qty': 10,
}, {
'order_id': self.so.id,
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 5,
}, {
'order_id': self.so.id,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 5,
}, {
'order_id': self.so.id,
'product_id': self.product_order_timesheet1.id,
'product_uom_qty': 2,
}])
self.so.action_confirm()

View file

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from .common import TestCommonSaleTimesheet
@tagged('-at_install', 'post_install')
class TestEditSoLineTimesheet(TestCommonSaleTimesheet):
def setUp(self):
super().setUp()
self.task_rate_task = self.env['project.task'].create({
'name': 'Task',
'project_id': self.project_task_rate.id,
'sale_line_id': self.so.order_line[0].id,
})
def test_sol_no_change_if_edited(self):
""" Check if a sol manually edited, does not change with a change of sol in the task.
Test Case:
=========
1) create some timesheets on this task,
2) edit a SOL of a timesheet in this task,
3) check if the edited SOL has the one selected and is not the one in the task,
4) change the sol on the task,
5) check if the timesheet in which the sol has manually edited, does not change but the another ones are the case.
"""
# 1) create some timesheets on this task
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': self.project_task_rate.id,
'task_id': self.task_rate_task.id,
'unit_amount': 5,
'employee_id': self.employee_manager.id
})
timesheet._compute_so_line()
edited_timesheet = timesheet.copy()
self.assertTrue(timesheet.so_line == edited_timesheet.so_line == self.task_rate_task.sale_line_id, "SOL in timesheet should be the same than the one in the task.")
self.assertEqual(timesheet.unit_amount + edited_timesheet.unit_amount, self.task_rate_task.sale_line_id.qty_delivered, "The quantity timesheeted should be increased the quantity delivered in the linked SOL.")
# 2) edit a SOL of a timesheet in this task
# Remark, we simulate the action done in the task form view
edited_timesheet.write({
"is_so_line_edited": True,
"so_line": self.so.order_line[1].id,
})
self.so.order_line._compute_qty_delivered()
# 3) check if the edited SOL has the one selected and is not the one in the task
self.assertNotEqual(edited_timesheet.so_line, self.task_rate_task.sale_line_id, "SOL in timesheet should be different than the one in the task.")
self.assertEqual(edited_timesheet.so_line, self.so.order_line[1], "SOL in timesheet is the one selected when we manually edit in the timesheet")
self.assertEqual(self.task_rate_task.sale_line_id.qty_delivered, timesheet.unit_amount, "The quantity delivered should be the quantity defined in the first timesheet of the task since the so_line in the second timesheet has manually been changed.")
# 4) change the sol on the task
self.task_rate_task.update({
'sale_line_id': self.so.order_line[-1].id,
})
timesheet._compute_so_line()
edited_timesheet._compute_so_line()
self.so.order_line._compute_qty_delivered()
# 5) check if the timesheet in which the sol has manually edited, does not change but the another ones are the case.
self.assertEqual(timesheet.so_line, self.task_rate_task.sale_line_id, "SOL in timesheet should be the same than the one in the task.")
self.assertNotEqual(edited_timesheet.so_line, self.task_rate_task.sale_line_id, "SOL in timesheet which is manually edited should be different than the one in the task.")
self.assertEqual(edited_timesheet.so_line, self.so.order_line[1], "SOL in timesheet should still be the same")

View file

@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from .common import TestCommonSaleTimesheet
from odoo.tests import tagged, Form
@tagged('post_install', '-at_install')
class TestProject(TestCommonSaleTimesheet):
def setUp(self):
super().setUp()
self.project_global.write({
'sale_line_id': self.so.order_line[0].id,
})
def test_fetch_sale_order_items(self):
""" Test _fetch_sale_order_items and _get_sale_order_items methods
This test will check we have the SOLs linked to the project and its tasks.
Test Case:
=========
1) No SOLs and SO should be found on a non billable project
2) Sol linked to the project should be fetched
3) SOL linked to the project and its task should be fetched
4) Add a employee mapping and check the SOL of this mapping is fetched with the others.
5) remove the SOL linked to the project and check the SOL linked to the task is fetched
6) Add an additional domain in the tasks to check if we can fetch with an additional filter
for instance, only the SOLs linked to the folded tasks.
7) Set allàw_billable=False and check no SOL is found since the project is not billable.
"""
self.assertFalse(self.project_non_billable._fetch_sale_order_items())
self.assertFalse(self.project_non_billable._get_sale_order_items())
self.assertFalse(self.project_non_billable._get_sale_orders())
sale_item = self.so.order_line[0]
self.env.invalidate_all()
expected_task_sale_order_items = self.project_global.tasks.sale_line_id
expected_sale_order_items = sale_item | expected_task_sale_order_items
self.assertEqual(self.project_global._fetch_sale_order_items(), expected_sale_order_items)
self.assertEqual(self.project_global._get_sale_order_items(), expected_sale_order_items)
self.assertEqual(self.project_global._get_sale_orders(), self.so)
task = self.env['project.task'].create({
'name': 'Task with SOL',
'project_id': self.project_global.id,
'sale_line_id': self.so.order_line[1].id,
})
self.assertEqual(task.project_id, self.project_global)
self.assertEqual(task.sale_line_id, self.so.order_line[1])
self.assertEqual(task.sale_order_id, self.so)
sale_lines = self.project_global._get_sale_order_items()
self.assertEqual(sale_lines, task.sale_line_id + self.project_global.sale_line_id, 'The Sales Order Items found should be the one linked to the project and the one of project task.')
self.assertEqual(self.project_global._get_sale_orders(), self.so, 'The Sales Order fetched should be the one of the both sale_lines fetched.')
employee_mapping = self.env['project.sale.line.employee.map'].create({
'project_id': self.project_global.id,
'employee_id': self.employee_user.id,
'sale_line_id': self.so.order_line[-1].id,
})
expected_sale_order_items |= employee_mapping.sale_line_id
self.assertEqual(self.project_global._get_sale_order_items(), expected_sale_order_items)
self.assertEqual(self.project_global._get_sale_orders(), expected_sale_order_items.order_id)
self.project_global.write({
'sale_line_id': False,
})
self.env.invalidate_all()
expected_task_sale_order_items |= task.sale_line_id
self.assertEqual(self.project_global._get_sale_order_items(), expected_task_sale_order_items | employee_mapping.sale_line_id)
self.assertEqual(self.project_global._get_sale_orders(), self.so)
new_stage = self.env['project.task.type'].create({
'name': 'New',
'sequence': 1,
'project_ids': [Command.set(self.project_global.ids)],
})
done_stage = self.env['project.task.type'].create({
'name': 'Done',
'sequence': 2,
'project_ids': [Command.set(self.project_global.ids)],
'fold': True,
})
task.write({
'stage_id': done_stage.id,
})
self.env.flush_all()
self.assertEqual(self.project_global._fetch_sale_order_items({'project.task': [('stage_id.fold', '=', False)]}), employee_mapping.sale_line_id)
self.assertEqual(self.project_global._fetch_sale_order_items({'project.task': [('stage_id.fold', '=', True)]}), task.sale_line_id | employee_mapping.sale_line_id)
task2 = self.env['project.task'].create({
'name': 'Task 2',
'project_id': self.project_global.id,
'sale_line_id': sale_item.id,
'stage_id': new_stage.id,
})
self.assertEqual(self.project_global._fetch_sale_order_items({'project.task': [('stage_id.fold', '=', False)]}), task2.sale_line_id | employee_mapping.sale_line_id)
self.assertEqual(self.project_global._fetch_sale_order_items({'project.task': [('stage_id.fold', '=', True)]}), task.sale_line_id | employee_mapping.sale_line_id)
self.project_global.allow_billable = False
self.assertFalse(self.project_global._get_sale_order_items())
self.assertFalse(self.project_global._get_sale_orders())
def test_compute_cost_in_employee_mappings(self):
self.assertFalse(self.project_global.sale_line_employee_ids)
employee_mapping = self.env['project.sale.line.employee.map'] \
.with_context(default_project_id=self.project_global.id) \
.create({
'employee_id': self.employee_manager.id,
'sale_line_id': self.project_global.sale_line_id.id,
})
self.assertFalse(employee_mapping.is_cost_changed)
self.assertEqual(employee_mapping.cost, self.employee_manager.hourly_cost)
employee_mapping.cost = 5
self.assertTrue(employee_mapping.is_cost_changed)
self.assertEqual(employee_mapping.cost, 5)
self.employee_manager.hourly_cost = 80
self.assertTrue(employee_mapping.is_cost_changed)
self.assertEqual(employee_mapping.cost, 5)
employee_mapping.employee_id = self.employee_user
self.assertTrue(employee_mapping.is_cost_changed)
self.assertEqual(employee_mapping.cost, 5)
employee_mapping.cost = self.employee_user.hourly_cost
employee_mapping.employee_id = self.employee_company_B
self.assertEqual(employee_mapping.cost, self.employee_company_B.hourly_cost)
def test_analytic_account_balance(self):
"""
1) Add new billable project
2) Add Employee/SOL mapping in the project
3) Add Task and Timesheet with the same user
4) Assert analytic_account_balance is calculated
"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_b.id,
})
sale_line = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet1.name,
'product_id': self.product_delivery_timesheet1.id,
'product_uom_qty': 1,
'product_uom': self.product_delivery_timesheet1.uom_id.id,
'price_unit': self.product_delivery_timesheet1.list_price,
'order_id': sale_order.id,
})
unit_amount = 6
expected_analytic_account_balance = - self.employee_user.hourly_cost * unit_amount
self.project_global.write({
'sale_line_id': sale_line.id,
'sale_line_employee_ids': [
Command.create({
'employee_id': self.employee_user.id,
'sale_line_id': sale_line.id,
}),
],
})
self.assertFalse(self.project_global.analytic_account_balance)
self.env['project.task'].create({
'name': 'task A',
'project_id': self.project_global.id,
'planned_hours': 10,
'timesheet_ids': [
Command.create({
'name': '/',
'employee_id': self.employee_user.id,
'unit_amount': unit_amount,
}),
],
})
self.assertEqual(self.project_global.analytic_account_balance, expected_analytic_account_balance)
def test_open_product_form_with_default_service_policy(self):
form = Form(self.env['product.product'].with_context(default_detailed_type='service', default_service_policy='delivered_timesheet'))
self.assertEqual('delivered_timesheet', form.service_policy)
def test_duplicate_project_allocated_hours(self):
self.project_global.allocated_hours = 10
self.assertEqual(self.project_global.copy().allocated_hours, 10)

View file

@ -0,0 +1,497 @@
# -*- 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.fields import Command
from odoo.tests import tagged
from odoo.tests.common import Form
@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',
'hourly_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({
'product_id': cls.product_order_timesheet1.id,
'product_uom_qty': 10,
'order_id': cls.sale_order_1.id,
})
cls.so1_line_deliver_no_task = SaleOrderLine.create({
'product_id': cls.product_delivery_timesheet1.id,
'product_uom_qty': 10,
'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,
'product_id': cls.product_delivery_timesheet3.id,
'product_uom_qty': 5,
})
cls.so2_line_deliver_project_template = SaleOrderLine.create({
'order_id': cls.sale_order_2.id,
'product_id': cls.product_delivery_timesheet5.id,
'product_uom_qty': 7,
})
cls.sale_order_2.action_confirm()
cls.project_project_rate = cls.project_task_rate.copy({
'name': 'Project with pricing_type="project_rate"',
'sale_order_id': cls.sale_order_1.id,
'sale_line_id': cls.so1_line_order_no_task.id,
})
# FIXME: [XBO] since the both projects have a SOL than the pricing_type should not be task_rate !
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 = cls.project_task_rate.copy({
'name': 'Project with pricing_type="employee_rate"',
'partner_id': cls.sale_order_1.partner_id.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,
})
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)
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,
'timesheet_product_id': self.product_delivery_timesheet3,
})
# 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,
})
# create wizard
wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({
'line_ids': [
Command.create({'product_id': self.product_delivery_timesheet3.id, 'price_unit': self.product_delivery_timesheet3.lst_price}),
],
})
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")
self.assertEqual(self.project_non_billable.pricing_type, 'fixed_rate', 'The pricing type of the project should be project rate since we linked a SO in the project.')
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,
})
# 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.pricing_type, 'employee_rate', 'The pricing type of the project should be employee rate since we have some mappings in this project.')
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.0, "The unit price of SOL 1 should be 15.0")
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.pricing_type, 'employee_rate', "Task in project 'employee rate' should be billed at employee rate")
self.assertEqual(task.sale_line_id, self.project_employee_rate.sale_line_id, "Task created in a project billed on 'employee rate' should be linked to the SOL defined in the project.")
self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project")
task.write({'sale_line_id': False}) # remove the SOL to check if the timesheet has no SOL when there is no SOL in the task
# 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_subtask.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,
})
self.assertTrue(task.allow_billable, "Task in project 'task rate' should be billed at task rate")
self.assertEqual(task.sale_line_id, self.so1_line_deliver_no_task, "The task should keep the same SOL since the partner_id has not changed when the project of the task has changed.")
self.assertEqual(task.partner_id, self.partner_a, "Task created in a project billed on 'employee rate' should have the same customer when it has been created.")
# the `subtask.sale_line_id` is consider to be recompute,
# but the result differ after the write of display_project_id without depend on it
task.flush_model(["sale_line_id"])
# move subtask into task rate project
subtask.write({
'display_project_id': self.project_task_rate2.id,
})
self.assertTrue(subtask.allow_billable, "Subtask should keep the billable type from its parent, even when they are moved into another project")
self.assertEqual(subtask.sale_line_id, task.sale_line_id, "Subtask should keep the same sale order line than their mother, even when they are moved into another project")
# create a second task in employee rate project
task2 = Task.with_context(default_project_id=self.project_employee_rate.id).create({
'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']
# create a task
task = Task.with_context(default_project_id=self.project_task_rate.id).create({
'name': 'first task',
})
self.assertEqual(task.sale_line_id, self.so2_line_deliver_project_task, "Task created in a project billed on 'task rate' should be linked to a SOL containing a prepaid service product and the remaining hours of this SOL should be greater than 0.")
self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'task rate' should have the same customer as the one from the project")
# log timesheet on task
timesheet1 = Timesheet.create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 50,
'employee_id': self.employee_manager.id,
})
self.assertEqual(task.sale_line_id, timesheet1.so_line, "The timesheet should be linked to the SOL associated to the task since the pricing type of the project is task rate.")
# create a subtask
subtask = Task.with_context(default_project_id=self.project_task_rate.id).create({
'name': 'first subtask task',
'parent_id': task.id,
'display_project_id': self.project_subtask.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.display_project_id.id,
'task_id': subtask.id,
'unit_amount': 50,
'employee_id': self.employee_user.id,
})
self.assertEqual(subtask.display_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")
# the `subtask.sale_line_id` is consider to be recompute,
# but the result differ after the write of project_id
task.flush_model(["sale_line_id"])
# move task and subtask into task rate project
task.write({
'project_id': self.project_employee_rate.id,
})
subtask.write({
'display_project_id': self.project_employee_rate.id,
})
self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Task moved in a employee rate billable project should keep its SOL because the partner_id has not changed too.")
self.assertEqual(task.partner_id, self.project_task_rate.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from its initial project.")
self.assertEqual(subtask.sale_line_id, subtask.parent_id.sale_line_id, "Subtask moved in a employee rate billable project should have the SOL of its parent since it keep its partner_id and this partner is different than the one in the destination project.")
self.assertEqual(subtask.partner_id, subtask.parent_id.partner_id, "Subtask moved in a project billed on 'employee rate' should keep its initial customer, that is the one of its parent.")
def test_customer_change_in_project(self):
""" Test when the user change the customer in a project
Test Case:
=========
1) Take project with pricing_type="fixed_rate", change the existing customer to another and check if the SO and SOL are equal to False.
2) Take project with pricing_type="employee_rate", change the existing customer to another and check if the SO and SOL are equal to False.
2.1) Check if the SOL in mapping is also equal to False
"""
# 1) Take project with pricing_type="fixed_rate", change the existing customer to another and check if the SO and SOL are equal to False.
self.project_project_rate.write({
'partner_id': self.partner_2.id,
})
self.assertFalse(self.project_project_rate.sale_order_id, "The SO in the project should be False because the previous SO customer does not match the actual customer of the project.")
self.assertFalse(self.project_project_rate.sale_line_id, "The SOL in the project should be False because the previous SOL customer does not match the actual customer of the project.")
self.assertEqual(self.project_project_rate.pricing_type, 'task_rate', 'Since there is no SO and SOL in the project, the pricing type should be task rate.')
# 2) Take project with pricing_type="employee_rate", change the existing customer to another and check if the SO and SOL are equal to False.
self.project_employee_rate.write({
'partner_id': self.partner_2.id,
})
self.assertFalse(self.project_employee_rate.sale_order_id, "The SO in the project should be False because the previous SO customer does not match the actual customer of the project.")
self.assertFalse(self.project_employee_rate.sale_line_id, "The SOL in the project should be False because the previous SOL customer does not match the actual customer of the project.")
# 2.1) Check if the SOL in mapping is also equal to False
self.assertFalse(self.project_employee_rate_manager.sale_line_id, "The SOL in the mapping should be False because the actual customer in the project has not this SOL.")
self.assertFalse(self.project_employee_rate_user.sale_line_id, "The SOL in the mapping should be False because the actual customer in the project has not this SOL.")
self.assertEqual(self.project_employee_rate.pricing_type, 'employee_rate', 'Since the mappings have not been removed, the pricing type should remain the same, that is employee rate.')
def test_project_form_view(self):
""" Test if in the form view, the partner is correctly computed when the user adds a mapping
Test Case:
=========
1) Use the Form class to create a project with a form view
2) Define a billable project
3) Create an employee mapping in this project
4) Check if the partner_id and pricing_type fields have been changed
"""
with Form(self.env['project.project'].with_context({'tracking_disable': True})) as project_form:
project_form.name = 'Test Billable Project'
project_form.allow_billable = True
# `sale_line_employee_ids` is not visible if `partner_id` is not set
# As the behavior of the test is to check the partner on the project
# is set to the partner of the order line, temporary make the field visible
# even if it's not the case in the reality, in the web client
# {'invisible': ['|', ('allow_billable', '=', False), ('partner_id', '=', False)]}
project_form._view['modifiers']['sale_line_employee_ids']['invisible'] = False
with project_form.sale_line_employee_ids.new() as mapping_form:
mapping_form.employee_id = self.employee_manager
mapping_form.sale_line_id = self.so.order_line[:1]
self.assertEqual(project_form.partner_id, self.so.partner_id, 'The partner should be the one defined the SO linked to the SOL defined in the mapping.')
project = project_form.save()
self.assertEqual(project.pricing_type, 'employee_rate', 'Since there is a mapping in this project, the pricing type should be employee rate.')
def test_take_into_account_invoicing_app_legacy(self):
""" Test the timesheets linked to a invoice determined as a invoiced imported form app legacy
are still considered as billed even if the state of those invoices is cancelled.
Since the account_accountant module is not in the dependencies of sale_timesheet module,
this test will manually set the state and payment_status to be in the same condition
than the feature "Invoicing Switch Threshold".
"""
self.sale_order_1.action_confirm()
timesheet1 = self.env['account.analytic.line'].create({
'name': '/',
'project_id': self.project_task_rate.id,
'unit_amount': 1,
'so_line': self.so1_line_deliver_no_task.id,
'is_so_line_edited': True,
'employee_id': self.employee_user.id,
})
self.assertEqual(self.so1_line_deliver_no_task.qty_delivered, timesheet1.unit_amount)
invoice1 = self.sale_order_1._create_invoices()[0]
invoice1.action_post()
self.assertEqual(self.so1_line_deliver_no_task.qty_invoiced, 1)
self.assertEqual(timesheet1.timesheet_invoice_id, invoice1)
timesheet2 = self.env['account.analytic.line'].create({
'project_id': self.project_task_rate.id,
'unit_amount': 2,
'so_line': self.so1_line_deliver_no_task.id,
'is_so_line_edited': True,
'employee_id': self.employee_user.id,
})
self.assertEqual(self.so1_line_deliver_no_task.qty_delivered, timesheet1.unit_amount + timesheet2.unit_amount)
invoice1.write({'state': 'cancel', 'payment_state': 'invoicing_legacy'})
self.assertEqual(self.so1_line_deliver_no_task.qty_invoiced, timesheet1.unit_amount)
invoice2 = self.sale_order_1._create_invoices()[0]
invoice2.action_post()
self.assertEqual(self.so1_line_deliver_no_task.qty_invoiced, timesheet1.unit_amount + timesheet2.unit_amount)
self.assertEqual(timesheet1.timesheet_invoice_id, invoice1)
self.assertEqual(timesheet2.timesheet_invoice_id, invoice2)

View file

@ -0,0 +1,35 @@
# -*- 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,
'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")

View file

@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from .common import TestCommonSaleTimesheet
@tagged('-at_install', 'post_install')
class TestProjectPricingType(TestCommonSaleTimesheet):
def test_pricing_type(self):
""" Test the _compute_pricing_type when the user add a sales order item or some employee mappings in the project
Test Case:
=========
1) Take a project non billable and check if the pricing_type is equal to False
2) Set allow_billable to True and check if the pricing_type is equal to task_rate (if no SOL and no mappings)
3) Set a customer and a SOL in the project and check if the pricing_type is equal to fixed_rate (project rate)
4) Set a employee mapping and check if the pricing_type is equal to employee_rate
"""
# 1) Take a project non billable and check if the pricing_type is equal to False
project = self.project_non_billable
self.assertFalse(project.allow_billable, 'The allow_billable should be false if the project is non billable.')
self.assertFalse(project.pricing_type, 'The pricing type of this project should be equal to False since it is non billable.')
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'task_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'employee_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('=', False)))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'task_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'employee_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('!=', False)))
# 2) Set allow_billable to True and check if the pricing_type is equal to task_rate (if no SOL and no mappings)
project.write({
'allow_billable': True,
})
self.assertTrue(project.allow_billable, 'The allow_billable should be updated and equal to True.')
self.assertFalse(project.sale_order_id, 'The sales order should be unset.')
self.assertFalse(project.sale_line_id, 'The sales order item should be unset.')
self.assertFalse(project.sale_line_employee_ids, 'The employee mappings should be empty.')
self.assertEqual(project.pricing_type, 'task_rate', 'The pricing type should be equal to task_rate.')
self.assertTrue(project.filtered_domain(project._search_pricing_type('=', 'task_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'employee_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', False)))
self.assertFalse(project.filtered_domain(project._search_pricing_type('!=', 'task_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'employee_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', False)))
# 3) Set a customer and a SOL in the project and check if the pricing_type is equal to fixed_rate (project rate)
project.write({
'partner_id': self.partner_b.id,
'sale_line_id': self.so.order_line[0].id,
})
self.assertEqual(project.sale_order_id, self.so, 'The sales order should be equal to the one set in the project.')
self.assertEqual(project.sale_line_id, self.so.order_line[0], 'The sales order item should be the one chosen.')
self.assertEqual(project.pricing_type, 'fixed_rate', 'The pricing type should be equal to fixed_rate since the project has a sales order item.')
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'task_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'employee_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', False)))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'task_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('!=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'employee_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', False)))
# 4) Set a employee mapping and check if the pricing_type is equal to employee_rate
project.write({
'sale_line_employee_ids': [(0, 0, {
'employee_id': self.employee_user.id,
'sale_line_id': self.so.order_line[1].id,
})]
})
self.assertEqual(len(project.sale_line_employee_ids), 1, 'The project should have an employee mapping.')
self.assertEqual(project.pricing_type, 'employee_rate', 'The pricing type should be equal to employee_rate since the project has an employee mapping.')
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'task_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('=', 'employee_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', False)))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'task_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('!=', 'employee_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', False)))
# Even if the project has no sales order item, since it has an employee mapping, the pricing type must be equal to employee_rate.
project.write({
'sale_line_id': False,
})
self.assertFalse(project.sale_order_id, 'The sales order of the project should be empty.')
self.assertFalse(project.sale_line_id, 'The sales order item of the project should be empty.')
self.assertEqual(project.pricing_type, 'employee_rate', 'The pricing type should always be equal to employee_rate.')
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'task_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('=', 'employee_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('=', False)))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'task_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(project._search_pricing_type('!=', 'employee_rate')))
self.assertTrue(project.filtered_domain(project._search_pricing_type('!=', False)))

View file

@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from .common import TestCommonSaleTimesheet
@tagged('-at_install', 'post_install')
class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.task = cls.env['project.task'].create({
'name': 'Test',
'project_id': cls.project_task_rate.id,
})
cls.project_profitability_items_empty = {
'revenues': {'data': [], 'total': {'to_invoice': 0.0, 'invoiced': 0.0}},
'costs': {'data': [], 'total': {'to_bill': 0.0, 'billed': 0.0}},
}
def test_profitability_of_non_billable_project(self):
""" Test no data is found for the project profitability since the project is not billable
even if it is linked to a sale order items.
"""
self.assertFalse(self.project_non_billable.allow_billable)
self.assertDictEqual(
self.project_non_billable._get_profitability_items(False),
self.project_profitability_items_empty,
)
self.project_non_billable.write({'sale_line_id': self.so.order_line[0].id})
self.assertDictEqual(
self.project_non_billable._get_profitability_items(False),
self.project_profitability_items_empty,
"Even if the project has a sale order item linked the project profitability should not be computed since it is not billable."
)
def test_get_project_profitability_items(self):
""" Test _get_project_profitability_items method to ensure the project profitability
is correctly computed as expected.
"""
sale_order = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
'partner_id': self.partner_b.id,
'partner_invoice_id': self.partner_b.id,
'partner_shipping_id': self.partner_b.id,
})
SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True, default_order_id=sale_order.id)
delivery_service_order_line = SaleOrderLine.create({
'product_id': self.product_delivery_manual1.id,
'product_uom_qty': 5,
})
sale_order.action_confirm()
self.task.write({'sale_line_id': delivery_service_order_line.id})
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
self.project_profitability_items_empty,
'No timesheets has been recorded in the task and no product has been deelivered in the SO linked so the project profitability has no data found.'
)
Timesheet = self.env['account.analytic.line'].with_context(
default_task_id=self.task.id,
)
timesheet1 = Timesheet.create({
'name': 'Timesheet 1',
'employee_id': self.employee_user.id,
'project_id': self.project_task_rate.id,
'unit_amount': 3.0,
})
timesheet2 = Timesheet.create({
'name': 'Timesheet 2',
'employee_id': self.employee_user.id,
'project_id': self.project_task_rate.id,
'unit_amount': 2.0,
})
sequence_per_invoice_type = self.project_task_rate._get_profitability_sequence_per_invoice_type()
self.assertIn('billable_time', sequence_per_invoice_type)
self.assertIn('billable_fixed', sequence_per_invoice_type)
self.assertIn('billable_milestones', sequence_per_invoice_type)
self.assertIn('billable_manual', sequence_per_invoice_type)
self.assertEqual(self.task.sale_line_id, delivery_service_order_line)
self.assertEqual((timesheet1 + timesheet2).so_line, delivery_service_order_line)
self.assertEqual(delivery_service_order_line.qty_delivered, 0.0, 'The service type is not timesheet but manual so the quantity delivered is not increased by the timesheets linked.')
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [],
'total': {'to_invoice': 0.0, 'invoiced': 0.0},
},
'costs': {
'data': [
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
},
},
}
)
timesheet3 = Timesheet.create({
'name': 'Timesheet 3',
'employee_id': self.employee_manager.id,
'project_id': self.project_task_rate.id,
'unit_amount': 1.0,
'so_line': False,
'is_so_line_edited': True,
})
self.assertFalse(timesheet3.so_line, 'This timesheet should be non billable since the user manually empty the SOL.')
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [],
'total': {'to_invoice': 0.0, 'invoiced': 0.0},
},
'costs': {
'data': [
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed':
(timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
+ timesheet3.unit_amount * -self.employee_manager.hourly_cost,
},
},
},
'The previous costs should remains and the cost of the third timesheet should be added.'
)
delivery_timesheet_order_line = SaleOrderLine.create({
'product_id': self.product_delivery_timesheet1.id,
'product_uom_qty': 5,
})
self.task.write({'sale_line_id': delivery_timesheet_order_line.id})
billable_timesheets = timesheet1 + timesheet2
self.assertEqual(billable_timesheets.so_line, delivery_timesheet_order_line, 'The SOL of the timesheets should be the one of the task.')
self.assertEqual(delivery_timesheet_order_line.qty_delivered, timesheet1.unit_amount + timesheet2.unit_amount)
self.assertEqual(
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [
{'id': 'billable_time', 'sequence': sequence_per_invoice_type['billable_time'], 'to_invoice': delivery_timesheet_order_line.untaxed_amount_to_invoice, 'invoiced': 0.0},
],
'total': {'to_invoice': delivery_timesheet_order_line.untaxed_amount_to_invoice, 'invoiced': 0.0},
},
'costs': {
'data': [
{
'id': 'billable_time',
'sequence': sequence_per_invoice_type['billable_time'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed':
(timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
+ timesheet3.unit_amount * -self.employee_manager.hourly_cost,
},
},
},
)
milestone_order_line = SaleOrderLine.create({
'product_id': self.product_milestone.id,
'product_uom_qty': 1,
})
task2 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Test',
'project_id': self.project_task_rate.id,
'sale_line_id': milestone_order_line.id,
})
task2_timesheet = Timesheet.with_context(default_task_id=task2.id).create({
'name': '/',
'project_id': self.project_task_rate.id,
'employee_id': self.employee_user.id,
'unit_amount': 1,
})
self.assertEqual(task2_timesheet.so_line, milestone_order_line)
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertFalse([data for data in profitability_items['revenues']['data'] if data['id'] == 'billable_milestones'])
self.assertDictEqual(
[data for data in profitability_items['costs']['data'] if data['id'] == 'billable_milestones'][0],
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'], 'to_bill': 0.0, 'billed': task2_timesheet.amount},
)
milestone_order_line.qty_delivered = 1
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertDictEqual(
[data for data in profitability_items['revenues']['data'] if data['id'] == 'billable_milestones'][0],
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'], 'to_invoice': milestone_order_line.untaxed_amount_to_invoice, 'invoiced': 0.0},
)
task2_timesheet.unlink()
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertFalse([data for data in profitability_items['revenues']['data'] if data['id'] == 'billable_milestones'])
self.assertFalse([data for data in profitability_items['costs']['data'] if data['id'] == 'billable_milestones'])

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from freezegun import freeze_time
from odoo.tests import tagged
from odoo.tools import format_amount
from odoo.addons.project.tests.test_project_update_flow import TestProjectUpdate
@tagged('-at_install', 'post_install')
class TestProjectUpdateSaleTimesheet(TestProjectUpdate):
def test_project_update_description_profitability(self):
self.project_pigs.allow_billable = True
template_values = self.env['project.update']._get_template_values(self.project_pigs)
# Store the formatted amount in the same currency as the project to guarantee the same unit
comparison_amount = format_amount(self.env, 0.0, self.project_pigs.currency_id)
self.assertEqual(template_values['profitability']['costs'], 0.0, "Project costs used in the template should be well defined")
self.assertEqual(template_values['profitability']['costs_formatted'], comparison_amount, "Project costs used in the template should be well defined")
self.assertEqual(template_values['profitability']['revenues'], 0.0, "Project revenues used in the template should be well defined")
self.assertEqual(template_values['profitability']['revenues_formatted'], comparison_amount, "Project revenues used in the template should be well defined")
self.assertEqual(template_values['profitability']['margin'], 0, "Margin used in the template should be well defined")
self.assertEqual(template_values['profitability']['margin_formatted'], comparison_amount, "Margin formatted used in the template should be well defined")
self.assertEqual(template_values['profitability']['margin_percentage'], "0", "Margin percentage used in the template should be well defined")
def test_project_update_panel_profitability_no_billable(self):
try:
self.project_pigs.action_view_timesheet()
except Exception as e:
raise AssertionError("Error raised unexpectedly while calling the action defined in profitalities action panel data ! Exception : " + e.args[0])
try:
self.project_pigs.action_view_timesheet()
except Exception as e:
raise AssertionError("Error raised unexpectedly while calling the action defined in profitalities action panel data ! Exception : " + e.args[0])

View file

@ -0,0 +1,371 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet
from odoo.fields import Date
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_no'].write(service_values)
service_values['expense_policy'] = 'cost'
cls.company_data['product_order_cost'].write(service_values)
cls.company_data['product_delivery_cost'].write(service_values)
service_values['expense_policy'] = 'sales_price'
cls.company_data['product_order_sales_price'].write(service_values)
cls.company_data['product_delivery_sales_price'].write(service_values)
# create AA, SO and invoices
cls.analytic_plan = cls.env['account.analytic.plan'].create({
'name': 'Plan',
'company_id': cls.company_data['company'].id,
})
cls.analytic_account = cls.env['account.analytic.account'].create({
'name': 'Test AA',
'code': 'TESTSALE_TIMESHEET_REINVOICE',
'company_id': cls.company_data['company'].id,
'plan_id': cls.analytic_plan.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. """
# Required for `analytic_account_id` to be visible in the view
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
# create SO line and confirm SO (with only one line)
sale_order_line1 = self.env['sale.order.line'].create({
'product_id': self.company_data['product_order_cost'].id,
'product_uom_qty': 2,
'order_id': self.sale_order.id,
})
sale_order_line2 = self.env['sale.order.line'].create({
'product_id': self.company_data['product_delivery_cost'].id,
'product_uom_qty': 4,
'order_id': self.sale_order.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.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_order_cost']
line_form.quantity = 3.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
with move_form.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_delivery_cost']
line_form.quantity = 3.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
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.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_order_cost']
line_form.quantity = 2.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
with move_form.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_delivery_cost']
line_form.quantity = 2.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
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.
"""
# Required for `analytic_account_id` to be visible in the view
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
# create SO line and confirm SO (with only one line)
sale_order_line1 = self.env['sale.order.line'].create({
'product_id': self.company_data['product_delivery_sales_price'].id,
'product_uom_qty': 2,
'qty_delivered': 1,
'order_id': self.sale_order.id,
})
sale_order_line2 = self.env['sale.order.line'].create({
'product_id': self.company_data['product_order_sales_price'].id,
'product_uom_qty': 3,
'qty_delivered': 1,
'order_id': self.sale_order.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.invoice_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_distribution = {self.analytic_account.id: 100}
with move_form.invoice_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_distribution = {self.analytic_account.id: 100}
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.invoice_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_distribution = {self.analytic_account.id: 100}
with move_form.invoice_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_distribution = {self.analytic_account.id: 100}
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. """
# Required for `analytic_account_id` to be visible in the view
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
# confirm SO
sale_order_line = self.env['sale.order.line'].create({
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 2,
'qty_delivered': 1,
'order_id': self.sale_order.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.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_order_no']
line_form.quantity = 3.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
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")
def test_reversed_invoice_reinvoice_with_period(self):
"""
Tests that when reversing an invoice of timesheet and selecting a time
period, the qty to invoice is correctly found
Business flow:
Create a sale order and deliver some hours (invoiced = 0)
Create an invoice
Confirm (invoiced = 1)
Add Credit Note
Confirm (invoiced = 0)
Go back to the SO
Create an invoice
Select a time period [1 week ago, 1 week in the future]
Confirm
-> Fails if there is nothing to invoice
"""
product = self.env['product.product'].create({
'name': "Service delivered, create task in global project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'service_policy': 'delivered_timesheet',
'invoice_policy': 'delivery',
'default_code': 'SERV-DELI2',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
'project_id': self.project_global.id,
'taxes_id': False,
'property_account_income_id': self.account_sale.id,
})
today = Date.context_today(self.env.user)
# Creates a sales order for quantity 3
so_form = Form(self.env['sale.order'])
so_form.partner_id = self.env['res.partner'].create({'name': 'Toto'})
with so_form.order_line.new() as line:
line.product_id = product
line.product_uom_qty = 3.0
sale_order = so_form.save()
sale_order.action_confirm()
# "Deliver" 1 of 3
task = sale_order.tasks_ids
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 1,
'employee_id': self.employee_user.id,
'company_id': self.company_data['company'].id,
})
context = {
"active_model": 'sale.order',
"active_ids": [sale_order.id],
"active_id": sale_order.id,
'open_invoices': True,
}
# Invoice the 1
wizard = self.env['sale.advance.payment.inv'].with_context(context).create({
'advance_payment_method': 'delivered'
})
invoice_dict = wizard.create_invoices()
# Confirm the invoice
invoice = self.env['account.move'].browse(invoice_dict['res_id'])
invoice.action_post()
# Refund the invoice
wiz_context = {
'active_model': 'account.move',
'active_ids': [invoice.id],
'default_journal_id': self.company_data['default_journal_sale'].id
}
refund_invoice_wiz = self.env['account.move.reversal'].with_context(wiz_context).create({
'reason': 'please reverse :c',
'refund_method': 'refund',
'date': today,
})
refund_invoice = self.env['account.move'].browse(refund_invoice_wiz.reverse_moves()['res_id'])
refund_invoice.action_post()
# reversing with action_reverse and then action_post does not reset the invoice_status to 'to invoice' in tests
# Recreate wizard to get the new invoices created
wizard = self.env['sale.advance.payment.inv'].with_context(context).create({
'advance_payment_method': 'delivered',
'date_start_invoice_timesheet': today - timedelta(days=7),
'date_end_invoice_timesheet': today + timedelta(days=7)
})
# The actual test :
wizard.create_invoices() # No exception should be raised, there is indeed something to be invoiced since it was reversed

View file

@ -0,0 +1,835 @@
# 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,
})
self.assertTrue(sale_order_line.product_updatable)
self.sale_order.action_confirm()
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({
'product_id': product_service_task.id,
'product_uom_qty': 10,
'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.")
# delete timesheets before deleting the task, so as to trigger the error
# about linked sales order lines and not the one about linked timesheets
task.timesheet_ids.unlink()
# unlink automatically task from the SOL when deleting the task
task.unlink()
self.assertFalse(sale_order_line.task_id, "Deleting the task its should automatically unlink the task from SOL.")
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,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 5,
'product_uom': uom_days.id,
})
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({
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 10,
'order_id': self.sale_order.id,
})
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({
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 10,
'order_id': self.sale_order.id,
})
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({
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 7,
'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_ids, "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({
'product_id': self.product_delivery_timesheet5.id,
'product_uom_qty': 11,
'order_id': self.sale_order.id,
})
so_line2 = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet4.id,
'product_uom_qty': 10,
'order_id': self.sale_order.id,
})
so_line3 = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet5.id,
'product_uom_qty': 5,
'order_id': self.sale_order.id,
})
so_line4 = self.env['sale.order.line'].create({
'product_id': self.product_delivery_manual3.id,
'product_uom_qty': 4,
'order_id': self.sale_order.id,
})
so_line5 = self.env['sale.order.line'].create({
'product_id': product_deli_ts_tmpl.id,
'product_uom_qty': 8,
'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({
'product_id': self.product_order_timesheet3.id,
'product_uom_qty': 11,
'order_id': self.sale_order.id,
})
so_line2 = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet3.id,
'product_uom_qty': 10,
'order_id': self.sale_order.id,
})
so_line3 = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet4.id,
'product_uom_qty': 5,
'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({
'product_id': self.product_order_timesheet3.id,
'product_uom_qty': 10,
'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({
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 10,
'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,
'order_id': self.sale_order.id,
})
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({'display_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,
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 50,
})
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,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 5,
})
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")
def test_remaining_hours_prepaid_services(self):
""" Test if the remaining hours is correctly computed
Test Case:
=========
1) Check the remaining hours in the SOL containing a prepaid service product,
2) Create task in project with pricing type is equal to "task rate" and has the customer in the SO
and check if the remaining hours is equal to the remaining hours in the SOL,
3) Create timesheet in the task for this SOL and check if the remaining hours correctly decrease,
4) Change the SOL in the task and see if the remaining hours is correctly recomputed.
5) Create without storing the timesheet to check if remaining hours in SOL does not change.
"""
# 1) Check the remaining hours in the SOL containing a prepaid service product
prepaid_service_sol = self.so.order_line.filtered(lambda sol: sol.product_id.service_policy == 'ordered_prepaid')
self.assertEqual(len(prepaid_service_sol), 1, "It should only have one SOL with prepaid service product in this SO.")
self.assertEqual(prepaid_service_sol.remaining_hours, prepaid_service_sol.product_uom_qty - prepaid_service_sol.qty_delivered, "The remaining hours of this SOL should be equal to the ordered quantity minus the delivered quantity.")
# 2) Create task in project with pricing type is equal to "task rate" and has the customer in the SO
# and check if the remaining hours is equal to the remaining hours in the SOL,
task = self.env['project.task'].create({
'name': 'Test task',
'project_id': self.project_task_rate.id,
})
self.assertEqual(task.partner_id, self.project_task_rate.partner_id)
self.assertEqual(task.partner_id, self.so.partner_id)
self.assertEqual(task.remaining_hours_so, prepaid_service_sol.remaining_hours)
# 3) Create timesheet in the task for this SOL and check if the remaining hours correctly decrease
self.env['account.analytic.line'].create({
'name': 'Test Timesheet',
'project_id': self.project_task_rate.id,
'task_id': task.id,
'unit_amount': 1,
'employee_id': self.employee_user.id,
})
self.assertEqual(task.remaining_hours_so, 1, "Before the creation of a timesheet, the remaining hours was 2 hours, when we timesheet 1 hour, the remaining hours should be equal to 1 hour.")
self.assertEqual(prepaid_service_sol.remaining_hours, task.remaining_hours_so, "The remaining hours on the SOL should also be equal to 1 hour.")
# 4) Change the SOL in the task and see if the remaining hours is correctly recomputed.
task.update({
'sale_line_id': self.so.order_line[0].id,
})
self.assertEqual(task.remaining_hours_so, False, "Since the SOL doesn't contain a prepaid service product, the remaining_hours_so should be equal to False.")
self.assertEqual(prepaid_service_sol.remaining_hours, 2, "Since the timesheet on task has the same SOL than the one in the task, the remaining_hours should increase of 1 hour to be equal to 2 hours.")
# 5) Create without storing the timesheet to check if remaining hours in SOL does not change
timesheet = self.env['account.analytic.line'].new({
'name': 'Test Timesheet',
'project_id': self.project_task_rate.id,
'task_id': task.id,
'unit_amount': 1,
'so_line': prepaid_service_sol.id,
'is_so_line_edited': True,
'employee_id': self.employee_user.id,
})
self.assertEqual(timesheet.so_line, prepaid_service_sol, "The SOL should be the same than one containing the prepaid service product.")
self.assertEqual(prepaid_service_sol.remaining_hours, 2, "The remaining hours should not change.")
def test_several_uom_sol_to_planned_hours(self):
planned_hours_for_uom = {
'day': 8.0,
'hour': 1.0,
'unit': 1.0,
'gram': 0.0,
}
project = self.project_global.copy({'tasks': False})
Product = self.env['product.product']
product_vals = {
'type': 'service',
'service_type': 'timesheet',
'project_id': project.id,
'service_tracking': 'task_global_project',
}
SaleOrderLine = self.env['sale.order.line']
sol_vals = {
'product_uom_qty': 1,
'price_unit': 100,
'order_id': self.sale_order.id,
}
for uom_name in planned_hours_for_uom:
uom_id = self.env.ref('uom.product_uom_%s' % uom_name)
product_vals.update({
'name': uom_name,
'uom_id': uom_id.id,
'uom_po_id': uom_id.id,
})
product = Product.create(product_vals)
sol_vals.update({
'name': uom_name,
'product_id': product.id,
'product_uom': uom_id.id,
})
SaleOrderLine.create(sol_vals)
self.sale_order.action_confirm()
tasks = project.task_ids
for task in tasks:
self.assertEqual(task.planned_hours, planned_hours_for_uom[task.sale_line_id.name])
def test_add_product_analytic_account(self):
""" When we have a project with an analytic account and we add a product to the task,
the consequent invoice line should have the same analytic account as the project.
"""
# Ensure the SO has no analytic account to give to its SOLs
self.assertFalse(self.sale_order.analytic_account_id)
Product = self.env['product.product']
SaleOrderLine = self.env['sale.order.line']
# Create a SO with a service that creates a task
product_create = Product.create({
'name': 'Product that creates the task',
'type': 'service',
'service_type': 'timesheet',
'project_id': self.project_global.id,
'service_tracking': 'task_global_project',
})
sale_order_line_create = SaleOrderLine.create({
'order_id': self.sale_order.id,
'name': product_create.name,
'product_id': product_create.id,
'product_uom_qty': 5,
'product_uom': product_create.uom_id.id,
'price_unit': product_create.list_price,
})
self.sale_order.action_confirm()
# Add a SOL with a task_id to mimmic the "Add a product" flow on the task
product_add = Product.create({'name': 'Product added on task'})
SaleOrderLine.create({
'order_id': self.sale_order.id,
'name': product_add.name,
'product_id': product_add.id,
'product_uom_qty': 5,
'product_uom': product_add.uom_id.id,
'price_unit': product_add.list_price,
'task_id': sale_order_line_create.task_id.id,
})
self.sale_order._create_invoices()
# Check that the resulting invoice line and the project have the same analytic account
invoice_line = self.sale_order.invoice_ids.line_ids.filtered(lambda line: line.product_id == product_add)
self.assertEqual(invoice_line.analytic_distribution, {str(self.project_global.analytic_account_id.id): 100},
"SOL's analytic distribution should contain the project analytic account")
def test_sale_timesheet_invoice(self):
""" Test timesheet is correctly linked to an invoice when its SOL is invoiced
Test Cases:
==========
1) Create a SOL on a SO
2) Confirm the SO
3) Set the SOL on a new timesheet
4) Create an invoice for this SO
5) Check the timesheet is linked to the invoice
"""
so_line = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 10,
'order_id': self.sale_order.id,
})
self.sale_order.action_confirm()
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': so_line.task_id.project_id.id,
'task_id': so_line.task_id.id,
'unit_amount': 5,
'employee_id': self.employee_manager.id,
})
self.assertFalse(timesheet.timesheet_invoice_id)
invoice = self.sale_order._create_invoices()
invoice.action_post()
self.assertEqual(invoice, timesheet.timesheet_invoice_id)
def test_prevent_update_project_allocated_hours_after_confirming_quotation(self):
""" Test allocated hours in the project linked to a SO is not automatically updated
When the project is linked to a SO (confirmed quotation) the allocated
hours should not be recomputed when the quantity ordered of a product
is changed in the SO.
Test Cases:
==========
1) Create a SOL on a SO
2) Confirm the SO
3) Store the project allocated hour
4) Modify the SOL product qty
5) Check the project allocated hour is modify
"""
order_line = self.env['sale.order.line'].create({
'order_id': self.sale_order.id,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 8,
})
self.sale_order.action_confirm()
allocated_hours = order_line.project_id.allocated_hours
order_line.product_uom_qty = 10
self.assertEqual(allocated_hours, order_line.project_id.allocated_hours, 'Project allocated hours should not be changed.')
def test_different_uom_to_hours_on_sale_order_confirmation(self):
""" Verify correctness of a project's allocted hours for multiple UOMs.
The conversion to time should be processed as follows :
H : qty = uom_qty [Hours]
D : qty = uom_qty * 8 [Hours]
U : qty = uom_qty [Hours]
Other : qty = 0
Test Cases:
==========
1) Create a 4 SOL on a SO With different UOM
2) Confirm the SO
3) Check the project allocated hour is correctly set
4) Repeat with different timesheet encoding UOM
"""
self.env['sale.order.line'].create([{
'order_id': self.sale_order.id,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 2,
'product_uom': self.env.ref('uom.product_uom_day').id, # 16 hours
}, {
'order_id': self.sale_order.id,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 8,
'product_uom': self.env.ref('uom.product_uom_hour').id, # 8 hours
}, {
'order_id': self.sale_order.id,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 1,
'product_uom': self.env.ref('uom.product_uom_dozen').id, # 0 hours
}, {
'order_id': self.sale_order.id,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 6,
'product_uom': self.env.ref('uom.product_uom_unit').id, # 6 hours
}])
self.sale_order.action_confirm()
allocated_hours = self.sale_order.project_ids.allocated_hours
self.assertEqual(16 + 8 + 6, allocated_hours,
"Project's allocated hours should add up correctly.")
self.env.company.timesheet_encode_uom_id = self.env.ref('uom.product_uom_day')
so_copy = self.sale_order.copy()
so_copy.action_confirm()
self.assertEqual(allocated_hours, so_copy.project_ids.allocated_hours,
"Timesheet encoding shouldn't affect hours allocated.")
def test_timesheet_hours_delivered_rounding(self):
"""
Ensure hours are rounded consistently on SO & invoice.
"""
self.env.company.project_time_mode_id.rounding = 1.0
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,
})
for amount in (8.1, 8.5, 8.9):
order = self.sale_order.copy()
sol = order.order_line
order.action_confirm()
self.env['account.analytic.line'].create([{
'name': 'Test Line',
'project_id': sol.project_id.id,
'task_id': sol.task_id.id,
'unit_amount': amount,
'employee_id': self.employee_manager.id,
}])
invoice = order._create_invoices()
hours_delivered = sol._get_delivered_quantity_by_analytic([])[sol.id]
self.assertEqual(
order.timesheet_total_duration,
hours_delivered,
f"{amount} hours delivered should round the same for SO & timesheet",
)
self.assertEqual(
invoice.timesheet_total_duration,
hours_delivered,
f"{amount} hours delivered should round the same for invoice & timesheet",
)

View file

@ -0,0 +1,911 @@
# -*- 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.hr_timesheet.tests.test_timesheet import TestCommonTimesheet
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({
'product_id': self.product_order_timesheet4.id,
'product_uom_qty': 10,
'order_id': sale_order.id,
})
so_line_ordered_global_project = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet2.id,
'product_uom_qty': 50,
'order_id': sale_order.id,
})
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', "Timesheets without SO should be be 'non-billable'")
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({
'product_id': self.product_order_timesheet3.id,
'product_uom_qty': 3,
'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({
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 50,
'order_id': sale_order.id,
})
so_line_deliver_task_project = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 20,
'order_id': sale_order.id,
})
# 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({
'product_id': self.product_delivery_timesheet4.id,
'product_uom_qty': 5,
'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, 'billable_time', "Timesheets with an amount > 0 should be 'billable time'")
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({
'product_id': self.product_delivery_manual2.id,
'product_uom_qty': 50,
'order_id': sale_order.id,
})
so_line_manual_only_project = self.env['sale.order.line'].create({
'product_id': self.product_delivery_manual4.id,
'product_uom_qty': 20,
'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_manual', "Milestone timesheet goes in billable manual 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_manual', "Timesheets linked to SO line with ordered product shoulbe be billable fixed since it is a prepaid product.")
self.assertEqual(timesheet2.timesheet_invoice_type, 'non_billable', "Timesheets without SO should be be 'non-billable'")
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({
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 50,
'order_id': sale_order.id,
})
so_line_deliver_task_project = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 20,
'order_id': sale_order.id,
})
# 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')
self.assertTrue(all(line.invoice_status == "to invoice" for line in sale_order.order_line if line.qty_delivered != line.qty_invoiced),
"All lines that still have some quantity to be invoiced should have an invoice status of 'to invoice', regardless if they were considered for previous invoicing, but didn't belong to the timesheet domain")
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. """
self.env.user.employee_id = self.env['hr.employee'].create({'user_id': self.env.uid})
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_template.id).create({
'name': 'first task',
'partner_id': self.partner_b.id,
'planned_hours': 10,
'sale_line_id': self.so.order_line[0].id
})
Timesheet.create({
'project_id': self.project_template.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, 0, "No timesheet in project_global")
self.assertEqual(timesheet_count2, 1, "One timesheet in project_template")
self.assertEqual(len(task.timesheet_ids), 1, "The timesheet should be linked to task")
# change project of task, as the timesheet is not yet invoiced, the timesheet will change his project
task.write({
'project_id': self.project_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, 0, "No timesheet in project_template")
self.assertEqual(len(task.timesheet_ids), 1, "The timesheet still should be linked to task")
# Create an invoice
context = {
'active_model': 'sale.order',
'active_ids': [self.so.id],
'active_id': self.so.id,
'default_journal_id': self.company_data['default_journal_sale'].id
}
wizard = self.env['sale.advance.payment.inv'].with_context(context).create({
'advance_payment_method': 'delivered',
'date_start_invoice_timesheet': today - timedelta(days=4),
'date_end_invoice_timesheet': today,
})
wizard.create_invoices()
Timesheet.create({
'project_id': self.project_global.id,
'task_id': task.id,
'name': 'my second timesheet',
'unit_amount': 6,
})
self.assertEqual(Timesheet.search_count([('project_id', '=', self.project_global.id)]), 2, "2 timesheets in project_global")
# change project of task, the timesheet not yet invoiced will change its project. The timesheet already invoiced will not change his project.
task.write({
'project_id': self.project_template.id
})
timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_global.id)])
timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_template.id)])
self.assertEqual(timesheet_count1, 1, "Still one timesheet in project_global")
self.assertEqual(timesheet_count2, 1, "One timesheet in project_template")
self.assertEqual(len(task.timesheet_ids), 2, "The 2 timesheets still should be linked to task")
def test_change_customer_and_SOL_after_invoiced_timesheet(self):
sale_order1 = 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,
})
sale_order2 = self.env['sale.order'].create({
'partner_id': self.partner_b.id,
'partner_invoice_id': self.partner_b.id,
'partner_shipping_id': self.partner_b.id,
'pricelist_id': self.company_data['default_pricelist'].id,
})
so1_product_global_project_so_line = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 50,
'order_id': sale_order1.id,
})
so2_product_global_project_so_line = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 20,
'order_id': sale_order2.id,
})
sale_order1.action_confirm()
sale_order2.action_confirm()
task_so1 = self.env['project.task'].search([('sale_line_id', '=', so1_product_global_project_so_line.id)])
task_so2 = self.env['project.task'].search([('sale_line_id', '=', so2_product_global_project_so_line.id)])
self.assertEqual(self.partner_a, task_so1.partner_id, "The Customer of the first task should be equal to partner_a.")
self.assertEqual(self.partner_b, task_so2.partner_id, "The Customer of the second task should be equal to partner_b.")
self.assertEqual(sale_order1.partner_id, task_so1.partner_id, "The Customer of the first task should be equal to the Customer of the first Sales Order.")
self.assertEqual(sale_order2.partner_id, task_so2.partner_id, "The Customer of the second task should be equal to the Customer of the second Sales Order.")
task_so1_timesheet1 = self.env['account.analytic.line'].create({
'name': 'Test Line1',
'project_id': task_so1.project_id.id,
'task_id': task_so1.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
invoice = sale_order1._create_invoices()
invoice.action_post()
self.assertEqual(self.partner_a, task_so1_timesheet1.partner_id, "The Task's Timesheet entry should have the same partner than on the task 1 and Sales Order 1.")
task_so1_timesheet2 = self.env['account.analytic.line'].create({
'name': 'Test Line2',
'project_id': task_so1.project_id.id,
'task_id': task_so1.id,
'unit_amount': 3,
'employee_id': self.employee_user.id,
})
task_so1.write({
'partner_id': self.partner_b.id,
'sale_line_id': so2_product_global_project_so_line.id,
})
self.assertEqual(self.partner_a, task_so1_timesheet1.partner_id, "The Task's first Timesheet entry should not have changed as it was already invoiced (its partner should still be partner_a).")
self.assertEqual(self.partner_b, task_so1_timesheet2.partner_id, "The Task's second Timesheet entry should have its partner changed, as it was not invoiced and the Task's partner/customer changed.")
self.assertEqual(so1_product_global_project_so_line, task_so1_timesheet1.so_line, "The Task's first Timesheet entry should not have changed as it was already invoiced (its so_line should still be equal to the first Sales Order line).")
self.assertEqual(so2_product_global_project_so_line, task_so1_timesheet2.so_line, "The Task's second Timesheet entry should have it's so_line changed, as the Sales Order Item of the Task changed, and this entry was not invoiced.")
def test_timesheet_upsell(self):
""" Test timesheet upselling and email """
sale_order = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'user_id': self.user_employee_company_B.id,
})
# 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': sale_order.id,
'product_id': self.product_order_timesheet3.id,
'product_uom': uom_days.id,
})
sale_order.action_confirm()
task = sale_order_line.task_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': 8,
'employee_id': self.employee_manager.id,
})
sale_order._create_invoices()
last_message_id = self.env['mail.message'].search([('model', '=', 'sale.order'), ('res_id', '=', sale_order.id)], limit=1).id or 0
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
message_sent = self.env['mail.message'].search([
('id', '>', last_message_id),
('subject', 'like', 'Upsell'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
self.assertEqual(len(message_sent), 1, 'Sale Timesheet: An email should always be sent to the saleperson when the state of the sale order change to upselling')
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
message_sent = self.env['mail.message'].search([
('id', '>', last_message_id),
('subject', 'like', 'Upsell'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
self.assertEqual(len(message_sent), 1, 'Sale Timesheet: An email should only be sent to the saleperson when the state of the sale order change to upselling')
def test_timesheet_upsell_copied_so(self):
""" Test that copying a SO which had an upsell activity still create an upsell activity on the copy. """
sale_order = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'user_id': self.user_employee_company_B.id,
})
# 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': sale_order.id,
'product_id': self.product_order_timesheet3.id,
'product_uom': uom_days.id,
})
sale_order.action_confirm()
task = sale_order_line.task_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': 8,
'employee_id': self.employee_manager.id,
})
sale_order._create_invoices()
last_message_id = self.env['mail.message'].search([('model', '=', 'sale.order'), ('res_id', '=', sale_order.id)], limit=1).id or 0
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
message_sent = self.env['mail.message'].search([
('id', '>', last_message_id),
('subject', 'like', 'Upsell'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
self.assertEqual(len(message_sent), 1, 'Sale Timesheet: An email should always be sent to the saleperson when the state of the sale order change to upselling')
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
message_sent = self.env['mail.message'].search([
('id', '>', last_message_id),
('subject', 'like', 'Upsell'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
self.assertEqual(len(message_sent), 1, 'Sale Timesheet: An email should only be sent to the saleperson when the state of the sale order change to upselling')
sale_order = sale_order.copy()
sale_order.action_confirm()
task = sale_order.order_line.task_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': 8,
'employee_id': self.employee_manager.id,
})
sale_order._create_invoices()
last_message_id = self.env['mail.message'].search([('model', '=', 'sale.order'), ('res_id', '=', sale_order.id)], limit=1).id or 0
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
message_sent = self.env['mail.message'].search([
('id', '>', last_message_id),
('subject', 'like', 'Upsell'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
self.assertEqual(len(message_sent), 1, 'Sale Timesheet: An email should always be sent to the saleperson when the state of the sale order change to upselling')
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
message_sent = self.env['mail.message'].search([
('id', '>', last_message_id),
('subject', 'like', 'Upsell'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
self.assertEqual(len(message_sent), 1, 'Sale Timesheet: An email should only be sent to the saleperson when the state of the sale order change to upselling')
def test_unlink_timesheet(self):
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 = 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,
})
sale_order.action_confirm()
task = so_line.task_id
# let's log some timesheets
analytic_line = self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 50,
'employee_id': self.employee_manager.id,
})
move = sale_order._create_invoices()
self.assertEqual(analytic_line.timesheet_invoice_id, move, "The timesheet should be linked to move")
move.with_context(check_move_validity=False).line_ids[0].unlink()
self.assertFalse(analytic_line.timesheet_invoice_id, "The timesheet should have been unlinked from move")
def test_sale_order_with_multiple_project_templates(self):
"""Test when creating multiple projects for one sale order every project has its own allocated hours"""
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
})
project_template_1, project_template_2 = self.env['project.project'].create([
{'name': 'Template 1'},
{'name': 'Template 1'},
])
product_1, product_2 = self.env['product.product'].create([
{
'name': "Service with template 1",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'order',
'uom_id': self.uom_hour.id,
'uom_po_id': self.uom_hour.id,
'default_code': 'c1',
'service_tracking': 'task_in_project',
'project_id': False, # will create a project,
'project_template_id': project_template_1.id,
}, {
'name': "Service with template 2",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'order',
'uom_id': self.uom_hour.id,
'uom_po_id': self.uom_hour.id,
'default_code': 'c2',
'service_tracking': 'task_in_project',
'project_id': False, # will create a project,
'project_template_id': project_template_2.id,
},
])
sale_order_line_template_1, sale_order_line_template_2 = self.env['sale.order.line'].create([
{
'order_id': sale_order.id,
'name': product_1.name,
'product_id': product_1.id,
'product_uom_qty': 10,
'product_uom': product_1.uom_id.id,
'price_unit': product_1.list_price,
}, {
'order_id': sale_order.id,
'name': product_2.name,
'product_id': product_2.id,
'product_uom_qty': 5,
'product_uom': product_2.uom_id.id,
'price_unit': product_2.list_price,
},
])
sale_order.action_confirm()
self.assertEqual(10, sale_order_line_template_1.project_id.allocated_hours)
self.assertEqual(5, sale_order_line_template_2.project_id.allocated_hours)
class TestSaleTimesheetView(TestCommonTimesheet):
def test_get_view_timesheet_encode_uom(self):
""" Test the label of timesheet time spent fields according to the company encoding timesheet uom """
self.assert_get_view_timesheet_encode_uom([
('sale_timesheet.project_project_view_form', '//field[@name="display_cost"]', [None, 'Daily Cost']),
])

View file

@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
from odoo import fields
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet
from odoo.tests import tagged
from odoo.exceptions import UserError
@tagged('post_install', '-at_install')
class TestAccruedTimeSheetSaleOrders(TestCommonSaleTimesheet):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.sale_order = cls.env['sale.order'].create({
'partner_id': cls.partner_a.id,
'partner_invoice_id': cls.partner_a.id,
'partner_shipping_id': cls.partner_a.id,
'pricelist_id': cls.company_data['default_pricelist'].id,
'date_order': '2020-01-01',
})
so_line_deliver_global_project = cls.env['sale.order.line'].create({
'name': cls.product_delivery_timesheet2.name,
'product_id': cls.product_delivery_timesheet2.id,
'product_uom_qty': 50,
'product_uom': cls.product_delivery_timesheet2.uom_id.id,
'price_unit': cls.product_delivery_timesheet2.list_price,
'order_id': cls.sale_order.id,
})
cls.sale_order.action_confirm()
cls.task = cls.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
cls.account_revenue = cls.company_data['default_account_revenue']
def _log_hours(self, unit_amount, date):
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': self.task.project_id.id,
'task_id': self.task.id,
'unit_amount': unit_amount,
'employee_id': self.employee_manager.id,
'date': date,
})
def test_timesheet_accrued_entries(self):
# log 10 hours on 2020-01-02
self._log_hours(10, '2020-01-02')
# log 10 hours on 2020-01-05
self._log_hours(10, '2020-01-05')
wizard = self.env['account.accrued.orders.wizard'].with_context({
'active_model': 'sale.order',
'active_ids': self.sale_order.ids,
}).create({
'account_id': self.company_data['default_account_expense'].id,
'date': '2020-01-01',
})
# nothing to invoice on 2020-01-01
with self.assertRaises(UserError):
wizard.create_entries()
# 10 hours to invoice on 2020-01-03
wizard.date = fields.Date.to_date('2020-01-03')
self.assertRecordValues(self.env['account.move'].search(wizard.create_entries()['domain']).line_ids, [
# reverse move lines
{'account_id': self.account_revenue.id, 'debit': 900, 'credit': 0},
{'account_id': wizard.account_id.id, 'debit': 0, 'credit': 900},
# move lines
{'account_id': self.account_revenue.id, 'debit': 0, 'credit': 900},
{'account_id': wizard.account_id.id, 'debit': 900, 'credit': 0},
])
# 20 hours to invoice on 2020-01-07
wizard.date = fields.Date.to_date('2020-01-07')
self.assertRecordValues(self.env['account.move'].search(wizard.create_entries()['domain']).line_ids, [
# reverse move lines
{'account_id': self.account_revenue.id, 'debit': 1800, 'credit': 0},
{'account_id': wizard.account_id.id, 'debit': 0, 'credit': 1800},
# move lines
{'account_id': self.account_revenue.id, 'debit': 0, 'credit': 1800},
{'account_id': wizard.account_id.id, 'debit': 1800, 'credit': 0},
])
def test_timesheet_invoiced_accrued_entries(self):
# log 10 hours on 2020-01-02
self._log_hours(10, '2020-01-02')
# invoice on 2020-01-04
inv = self.sale_order._create_invoices()
inv.invoice_date = fields.Date.to_date('2020-01-04')
inv.action_post()
# log 10 hours on 2020-01-06
self._log_hours(10, '2020-01-06')
# invoice on 2020-01-08
inv = self.sale_order._create_invoices()
inv.invoice_date = fields.Date.to_date('2020-01-08')
inv.action_post()
wizard = self.env['account.accrued.orders.wizard'].with_context({
'active_model': 'sale.order',
'active_ids': self.sale_order.ids,
}).create({
'account_id': self.company_data['default_account_expense'].id,
'date': '2020-01-02',
})
self.assertRecordValues(self.env['account.move'].search(wizard.create_entries()['domain']).line_ids, [
# reverse move lines
{'account_id': self.account_revenue.id, 'debit': 900, 'credit': 0},
{'account_id': wizard.account_id.id, 'debit': 0, 'credit': 900},
# move lines
{'account_id': self.account_revenue.id, 'debit': 0, 'credit': 900},
{'account_id': wizard.account_id.id, 'debit': 900, 'credit': 0},
])
# nothing to invoice on 2020-01-05
wizard.date = fields.Date.to_date('2020-01-05')
with self.assertRaises(UserError):
wizard.create_entries()
# 20 hours to invoice on 2020-01-07
wizard.date = fields.Date.to_date('2020-01-07')
self.assertRecordValues(self.env['account.move'].search(wizard.create_entries()['domain']).line_ids, [
# reverse move lines
{'account_id': self.account_revenue.id, 'debit': 900, 'credit': 0},
{'account_id': wizard.account_id.id, 'debit': 0, 'credit': 900},
# move lines
{'account_id': self.account_revenue.id, 'debit': 0, 'credit': 900},
{'account_id': wizard.account_id.id, 'debit': 900, 'credit': 0},
])
# nothing to invoice on 2020-01-05
wizard.date = fields.Date.to_date('2020-01-09')
with self.assertRaises(UserError):
wizard.create_entries()

View file

@ -0,0 +1,45 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# -*- coding: utf-8 -*-
import logging
from odoo.tests import HttpCase, tagged
_logger = logging.getLogger(__name__)
@tagged('-at_install', 'post_install')
class TestUi(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
service_category_id = cls.env['product.category'].create({
'name': 'Services',
'parent_id': cls.env.ref('product.product_category_1').id,
}).id
uom_hour_id = cls.env.ref('uom.product_uom_hour').id
cls.prepaid_service_product = cls.env['product.product'].create({
'name': 'Service Product (Prepaid Hours)',
'categ_id': service_category_id,
'type': 'service',
'list_price': 250.00,
'standard_price': 190.00,
'uom_id': uom_hour_id,
'uom_po_id': uom_hour_id,
'service_policy': 'ordered_prepaid',
'service_tracking': 'no',
})
# Enable the "Milestones" feature to be able to create milestones on this tour.
cls.env['res.config.settings'] \
.create({'group_project_milestone': True}) \
.execute()
admin = cls.env.ref('base.user_admin')
admin.employee_id.hourly_cost = 75
def test_ui(self):
self.start_tour('/web', 'sale_timesheet_tour', login='admin', timeout=100)

View file

@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from .common import TestCommonSaleTimesheet
@tagged('-at_install', 'post_install')
class TestSoLineDeterminedInTimesheet(TestCommonSaleTimesheet):
def test_sol_determined_when_project_is_task_rate(self):
""" Test the sol give to the timesheet when the pricing type in the project is task rate
Test Case:
=========
1) Create Task in project with pricing_type='task_rate',
2) Compute the SOL for the task and check if we have the one containing the prepaid service product,
3) Create timesheet and check if the SOL in this timesheet is the one in the task,
4) Since the remaining hours of the prepaid service is equals to 0 hour,
when we create a new task, the SOL in this one should be equal to False
5) Change the SOL in the task and see if the SOL in the timesheet also changes.
"""
# 1) Create Task in project with pricing_type='task_rate'
task = self.env['project.task'].create({
'name': 'Task',
'project_id': self.project_task_rate.id,
})
# 2) Compute the SOL for the task and check if we have the one containing the prepaid service product
# task._compute_sale_line()
self.assertEqual(task.sale_line_id, self.so.order_line[-1], "The SOL in the task should be the one containing the prepaid service product.")
self.assertTrue(all(sol.qty_delivered == 0 for sol in self.so.order_line), "The quantity delivered should be equal to 0 because we have no timesheet for each SOL containing in the SO.")
# 3) Create timesheet and check if the SOL in this timesheet is the one in the task
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'unit_amount': 2,
'employee_id': self.employee_manager.id,
'project_id': self.project_task_rate.id,
'task_id': task.id,
})
self.assertEqual(timesheet.so_line, task.sale_line_id, "The SOL in the timesheet should be the same than the one in the task.")
self.assertEqual(self.so.order_line[-1].qty_delivered, 2, "The quantity delivered should be equal to 2.")
self.assertEqual(task.remaining_hours_so, 0, "The remaining hours on the SOL containing the prepaid service product should be equals to 0.")
# 4) Since the remaining hours of the prepaid service product is equals to 0 hour, when we create a new task, the SOL in this one should be equals to False
task2 = self.env['project.task'].create({
'name': 'Task 2',
'project_id': self.project_task_rate.id,
})
self.assertFalse(task2.sale_line_id, "The SOL in this task should be equal to False")
# 5) Change the SOL in the task and see if the SOL in the timesheet also changes.
task.update({'sale_line_id': self.so.order_line[0].id})
self.assertEqual(timesheet.so_line, task.sale_line_id, "The SOL in the timesheet should also change and be the same than the one in the task.")
def test_sol_determined_when_project_is_project_rate(self):
""" Test the sol give to the timesheet when the pricing type in the project is project rate
Test Case:
=========
1) Define a SO and SOL in the project,
2) Create task and check if the SOL is the one defined in the project,
3) Create timesheet in the task and check if the SOL in the timesheet is the one in the task,
4) Change the SOL in the task and check if the SOL in the timesheet has also changed.
"""
# 1) Define a SO and SOL in the project
self.project_project_rate = self.project_task_rate.copy({
'name': 'Project with pricing_type="project_rate"',
'sale_line_id': self.so.order_line[0].id,
})
# 2) Create task and check if the SOL is the one defined in the project
task = self.env['project.task'].create({
'name': 'Task',
'project_id': self.project_project_rate.id,
})
self.assertEqual(task.sale_line_id, self.so.order_line[0], "The SOL in the task should be the one containing the prepaid service product.")
self.assertTrue(all(sol.qty_delivered == 0 for sol in self.so.order_line), "The quantity delivered should be equal to 0 because we have no timesheet for each SOL containing in the SO.")
# 3) Create timesheet in the task and check if the SOL in the timesheet is the one in the task
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'unit_amount': 1,
'employee_id': self.employee_manager.id,
'project_id': self.project_project_rate.id,
'task_id': task.id,
})
self.assertTrue(timesheet.so_line == task.sale_line_id == self.so.order_line[0], "The SOL in the timesheet should be the same than the one in the task.")
self.assertEqual(self.so.order_line[0].qty_delivered, 1, "The quantity delivered should be equal to 1.")
# 4) Change the SOL in the task and check if the SOL in the timesheet has also changed
task.update({'sale_line_id': self.so.order_line[1].id})
self.assertTrue(timesheet.so_line == task.sale_line_id == self.so.order_line[1], "The SOL in the timesheet should also change and be the same than the one in the task.")
def test_sol_determined_when_project_is_employee_rate(self):
""" Test the sol give to the timesheet when the pricing type in the project is employee rate
Test Case:
=========
1) Define a SO, SOL mapping for an employee in the project,
2) Create task and check if the SOL is the one defined in the project,
3) Create timesheet in the task and check if the SOL in the timesheet is the one in the task,
4) Create timesheet in the task for the employee defined in the mapping and check if the SOL in this timesheet is the one defined in the mapping,
5) Change the SOL in the task and check if only the SOL in the timesheet which does not concerne about the mapping changes,
6) Change the SOL in the mapping and check if the timesheet conserne by the mapping has its SOL has been changed too.
"""
# 1) Define a SO, SOL and mapping for an employee in the project,
self.project_employee_rate = self.project_task_rate.copy({
'name': 'Project with pricing_type="employee_rate"',
'sale_line_id': self.so.order_line[0].id,
'sale_line_employee_ids': [(0, 0, {
'employee_id': self.employee_user.id,
'sale_line_id': self.so.order_line[1].id,
})]
})
mapping = self.project_employee_rate.sale_line_employee_ids
# 2) Create task and check if the SOL is the one defined in the project,
task = self.env['project.task'].create({
'name': 'Task',
'project_id': self.project_employee_rate.id,
})
self.assertEqual(task.sale_line_id, self.so.order_line[0], "The SOL in the task should be the one containing the prepaid service product.")
self.assertTrue(all(sol.qty_delivered == 0 for sol in self.so.order_line), "The quantity delivered should be equal to 0 because we have no timesheet for each SOL containing in the SO.")
# 3) Create timesheet in the task and check if the SOL in the timesheet is the one in the task,
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'unit_amount': 1,
'employee_id': self.employee_manager.id,
'project_id': self.project_employee_rate.id,
'task_id': task.id,
})
self.assertTrue(timesheet.so_line == task.sale_line_id == self.so.order_line[0], "The SOL in the timesheet should be the same than the one in the task.")
self.assertEqual(self.so.order_line[0].qty_delivered, 1, "The quantity delivered should be equal to 1 for all SOLs in the SO.")
# 4) Create timesheet in the task for the employee defined in the mapping and check if the SOL in this timesheet is the one defined in the # mapping,
employee_user_timesheet = timesheet.copy({
'name': 'Test Line Employee User',
'employee_id': self.employee_user.id,
'unit_amount': 2,
})
employee_user_timesheet._compute_so_line()
self.assertTrue(employee_user_timesheet.so_line == self.project_employee_rate.sale_line_employee_ids[0].sale_line_id == self.so.order_line[1], "The SOL in the timesheet should be the one defined in the mapping for the employee user.")
self.assertEqual(self.so.order_line[1].qty_delivered, 2, "The quantity delivered for this SOL should be equal to 2 hours.")
# 5) Change the SOL in the task and check if only the SOL in the timesheet which does not concerne about the mapping changes,
task.update({'sale_line_id': self.so.order_line[2].id})
self.assertTrue(timesheet.so_line == task.sale_line_id == self.so.order_line[2], "The SOL in the timesheet should also change and be the same than the one in the task.")
self.assertNotEqual(timesheet.so_line, employee_user_timesheet.so_line, "The SOL in the timesheet done by the employee user should not be the same than the one in the other timesheet in the task.")
# 6) Change the SOL in the mapping and check if the timesheet conserne by the mapping has its SOL has been changed too.
mapping.update({'sale_line_id': self.so.order_line[-1].id})
self.assertTrue(employee_user_timesheet.so_line == mapping.sale_line_id == self.so.order_line[-1], "The SOL in the timesheet done by the employee user should be the one defined in the mapping.")
self.assertNotEqual(timesheet.so_line, employee_user_timesheet.so_line, "The other timesheet should not have the SOL defined in the mapping.")
def test_no_so_line_if_project_non_billable(self):
""" Test if the timesheet created in task in non billable project does not have a SOL
Test Case:
=========
1) Create task in a non billable project,
2) Check if there is no SOL in task,
3) Create timesheet in the task and check if it does not contain any SOL.
4) Create a timesheet in the project without any task set for this timesheet.
5) Check the timesheet has no SOL too since the project is non billable.
"""
# 1) Create task in a non billable project,
task = self.env['project.task'].create({
'name': 'Test Task',
'project_id': self.project_non_billable.id,
'partner_id': self.partner_a.id,
})
# 2) Check if there is no SOL in task,
self.assertFalse(task.sale_line_id, 'No SOL should be linked in this task because the project is non billable.')
# 3) Create timesheet in the task and check if it does not contain any SOL.
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'unit_amount': 1,
'employee_id': self.employee_manager.id,
'project_id': task.project_id.id,
'task_id': task.id,
})
self.assertFalse(timesheet.so_line, 'No SOL should be linked in this timesheet because the project is non billable.')
# 4) Create a timesheet in the project without any task set for this timesheet.
timesheet1 = self.env['account.analytic.line'].create({
'name': 'Test Line 1',
'unit_amount': 1,
'project_id': task.project_id.id,
'employee_id': self.employee_manager.id,
})
# 5) Check the timesheet has no SOL too since the project is non billable.
self.assertFalse(timesheet1.so_line, 'This Timesheet is not billable since it has no task set and the project linked is not billable')
def test_tranfer_project(self):
""" Test if the SOL in timesheet is erased if the task of this timesheet changes the project
from a billable project to a non billable project
Test Case:
=========
1) Create task in project_task_rate,
2) Check if the task has the SOL which contain the prepaid service product,
3) Create timesheet in this task,
4) Check if the timesheet contains the same SOL than the task,
5) Move the task in a non billable project,
6) Check if the task and timesheet has no SOL.
"""
# 1) Create task in project_task_rate,
task = self.env['project.task'].create({
'name': 'Test Task',
'project_id': self.project_task_rate.id,
})
# 2) Check if the task has the SOL which contain the prepaid service product,
self.assertEqual(task.sale_line_id, self.so.order_line[-1], 'The SOL with prepaid service product should be linked to the task.')
# 3) Create timesheet in this task,
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'unit_amount': 1,
'employee_id': self.employee_manager.id,
'project_id': task.project_id.id,
'task_id': task.id,
})
# 4) Check if the timesheet contains the same SOL than the task,
self.assertEqual(timesheet.so_line, task.sale_line_id, 'The timesheet should have the same SOL than the task.')
# 5) Move the task in a non billable project,
task.write({'project_id': self.project_non_billable.id})
# 6) Check if the task and timesheet has no SOL.
self.assertFalse(timesheet.so_line, 'No SOL should be linked to the timesheet because the project is non billable')

View file

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from .common import TestCommonSaleTimesheet
@tagged('-at_install', 'post_install')
class TestUpsellWarning(TestCommonSaleTimesheet):
def test_display_upsell_warning(self):
""" Test to display an upsell warning
We display an upsell warning in SO when this following condition is satisfy in its SOL:
(qty_delivered / product_uom_qty) >= product_id.service_upsell_threshold
Test Case:
=========
1) Configure the upsell warning in prepaid service product
2) Create SO with a SOL containing this updated product,
3) Create Project and Task,
4) Timesheet in the task to satisfy the condition for the SOL to display an upsell warning,
5) Check if the SO has an 'sale.mail_act_sale_upsell' activity.
"""
# 1) Configure the upsell warning in prepaid service product
self.product_order_timesheet1.write({
'service_upsell_threshold': 0.5,
})
# 2) Create SO with a SOL containing this updated product
so = 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,
})
self.env['sale.order.line'].create({
'order_id': so.id,
'product_id': self.product_order_timesheet1.id,
'product_uom_qty': 10,
})
so.action_confirm()
# 3) Create Project and Task
project = self.env['project.project'].create({
'name': 'Project',
'allow_timesheets': True,
'allow_billable': True,
'partner_id': self.partner_a.id,
'analytic_account_id': self.analytic_account_sale.id,
})
task = self.env['project.task'].create({
'name': 'Task Test',
'project_id': project.id,
})
task._compute_sale_line()
# 4) Timesheet in the task to satisfy the condition for the SOL to display an upsell warning
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'unit_amount': 5,
'employee_id': self.employee_manager.id,
'project_id': project.id,
'task_id': task.id,
})
timesheet._compute_so_line()
so.order_line._compute_qty_delivered()
so.order_line._compute_invoice_status()
so._compute_invoice_status()
# Normally this method is called at the end of _compute_invoice_status and other compute method. Here, we simulate for invoice_status field
so._compute_field_value(so._fields['invoice_status'])
self.assertEqual(len(so.activity_search(['sale.mail_act_sale_upsell'])), 0, 'No upsell warning should appear in the SO.')
timesheet.write({
'unit_amount': 6,
})
timesheet._compute_so_line()
so.order_line._compute_qty_delivered()
so.order_line._compute_invoice_status()
so._compute_invoice_status()
# Normally this method is called at the end of _compute_invoice_status and other compute method. Here, we simulate for invoice_status field
so._compute_field_value(so._fields['invoice_status'])
# 5) Check if the SO has an 'sale.mail_act_sale_upsell' activity.
self.assertEqual(len(so.activity_search(['sale.mail_act_sale_upsell'])), 1, 'A upsell warning should appear in the SO.')
def test_display_upsell_warning_when_invoiced(self):
""" Test to display an upsell warning when threshold value (10000%) exceed while creating invoice.
We display an upsell warning in SO when this following condition is satisfy in its SOL:
(qty_delivered / product_uom_qty) >= product_id.service_upsell_threshold
Test Case:
=========
1) Configure the upsell warning in prepaid service product
2) Create SO with a SOL containing this updated product,
3) Create Project and Task,
4) Timesheet in the task to satisfy the condition for the SOL to display an upsell warning,
5) Create Invoice of the SO,
6) Check if the SO has an 'sale.mail_act_sale_upsell' activity.
"""
# 1) Configure the upsell warning in prepaid service product with 100 (10000%)
self.product_order_timesheet1.write({
'service_upsell_threshold': 100,
})
# 2) Create SO with a SOL containing this updated product
so = 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,
})
self.env['sale.order.line'].create({
'order_id': so.id,
'name': self.product_order_timesheet1.name,
'product_id': self.product_order_timesheet1.id,
'product_uom_qty': 1,
'price_unit': self.product_order_timesheet1.list_price,
})
so.action_confirm()
# 3) Create Project and Task
project = self.env['project.project'].create({
'name': 'Project',
'allow_timesheets': True,
'allow_billable': True,
'partner_id': self.partner_a.id,
'analytic_account_id': self.analytic_account_sale.id,
})
task = self.env['project.task'].create({
'name': 'Task Test',
'project_id': project.id,
'sale_line_id': so.order_line.id
})
# 4) Timesheet in the task to satisfy the condition for the SOL to display an upsell warning
self.env['account.analytic.line'].create({
'name': 'Test Line',
'unit_amount': 50,
'employee_id': self.employee_manager.id,
'project_id': project.id,
'task_id': task.id,
})
so.order_line._compute_qty_delivered()
# 5) Create Invoice of the SO
so._create_invoices()
# Normally this method is called at the end of _get_invoice_status and other compute method. Here, we simulate for invoice_status field
so._compute_field_value(so._fields['invoice_status'])
# 6) Check if the SO has an 'sale.mail_act_sale_upsell' activity.
self.assertEqual(len(so.activity_search(['sale.mail_act_sale_upsell'])), 0, 'No upsell warning should appear in the SO.')