19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -8,7 +8,6 @@ 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
@ -16,3 +15,9 @@ from . import test_sale_timesheet_ui
from . import test_project_pricing_type
from . import test_project_update
from . import test_sale_timesheet_accrued_entries
from . import test_sale_timesheet_report
from . import test_sale_timesheet_product_product
from . import test_sale_timesheet_product_template
from . import test_sale_timesheet_dashboard
from . import test_task_analysis
from . import test_performance

View file

@ -8,9 +8,10 @@ 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"
def setUpClass(cls):
super().setUpClass()
cls.env.user.group_ids |= cls.env.ref('hr.group_hr_user')
cls.company_data_2 = cls.setup_other_company()
cls.user_employee_company_B = mail_new_test_user(
cls.env,
@ -32,34 +33,43 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
company_id=cls.company_data_2['company'].id,
company_ids=[cls.company_data_2['company'].id, cls.env.company.id],
)
# What's important here is that this user does not have access to read Sales data,
# but can still log time on a timesheet.
cls.user_employee_without_sales_access = mail_new_test_user(
cls.env,
name='Tyrion Lannister Employee',
login='tyrion',
email='tyrion@example.com',
notification_type='email',
groups='project.group_project_manager,hr_timesheet.group_hr_timesheet_user',
)
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,
})
cls.employee_user, cls.employee_manager, \
cls.employee_company_B, cls.manager_company_B, \
cls.employee_without_sales_access = \
cls.env['hr.employee'].create([{
'name': 'Employee User',
'hourly_cost': 15,
}, {
'name': 'Employee Manager',
'hourly_cost': 45,
}, {
'name': 'Gregor Clegane',
'user_id': cls.user_employee_company_B.id,
'hourly_cost': 15,
}, {
'name': 'Cersei Lannister',
'user_id': cls.user_manager_company_B.id,
'hourly_cost': 45,
}, {
'name': 'Tyrion Lannister',
'user_id': cls.user_employee_without_sales_access.id,
'hourly_cost': 25,
}])
# 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_plan, _other_plans = cls.env['account.analytic.plan']._get_all_plans()
cls.analytic_account_sale_company_B = cls.env['account.analytic.account'].create({
'name': 'Project for selling timesheet Company B - AA',
'code': 'AA-2030',
@ -68,7 +78,7 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
})
# Create projects
Project = cls.env['project.project'].with_context(tracking_disable=True)
Project = cls.env['project.project']
cls.project_global.write({
'name': 'Project for selling timesheets',
'allow_timesheets': True,
@ -82,7 +92,7 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'allow_timesheets': True,
'allow_billable': True,
'partner_id': cls.partner_b.id,
'analytic_account_id': cls.analytic_account_sale.id,
'account_id': cls.analytic_account_sale.id,
})
cls.project_subtask = Project.create({
@ -108,7 +118,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -123,7 +132,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -138,7 +146,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -153,7 +160,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -168,7 +174,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -186,7 +191,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -201,7 +205,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -216,7 +219,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -231,7 +233,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -246,7 +247,6 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'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',
@ -255,10 +255,20 @@ class TestCommonSaleTimesheet(TestSaleProjectCommon):
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_service_delivered_timesheet = cls.env['product.product'].create({
'name': "Service timesheet",
'standard_price': 11,
'list_price': 13,
'type': 'service',
'service_tracking': 'no',
'project_id': False,
'invoice_policy': 'delivery',
'service_type': 'timesheet',
})
def setUp(self):
super().setUp()
self.so = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
self.so = 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,

View file

@ -30,6 +30,7 @@ class TestEditSoLineTimesheet(TestCommonSaleTimesheet):
# 1) create some timesheets on this task
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'auto_account_id': self.analytic_account_sale.id,
'project_id': self.project_task_rate.id,
'task_id': self.task_rate_task.id,
'unit_amount': 5,

View file

@ -0,0 +1,42 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale_timesheet.tests.test_sale_timesheet import TestSaleTimesheet
from odoo.tests import tagged
from odoo.fields import Command
@tagged('post_install', '-at_install')
class TestPerformanceTimesheet(TestSaleTimesheet):
def test_performance_billable_project_change_customer(self):
"""
Use case: change the partner of a billable project containing many tasks having no SOL, which should trigger _compute_sale_line_id() of all tasks.
We check if the number of queries does not increase proportionally to the number of tasks.
"""
project = self.env['project.project'].create({
'name': 'Perf Project',
'task_ids': [Command.create({'name': f'Task {i}'}) for i in range(50)]
})
self.assertFalse(project.task_ids.sale_line_id)
self.env.invalidate_all()
with self.assertQueryCount(85):
project.write({
'allow_billable': True,
'partner_id': self.partner_b.id,
})
self.assertTrue(project.task_ids.sale_line_id)
# Reset all tasks's SOL to False, double the number of tasks and run it again
project.allow_billable = False
self.assertFalse(project.task_ids.sale_line_id)
self.env['project.task'].create([{
'name': f'Task {i}',
'project_id': project.id,
} for i in range(50, 100)])
self.env.invalidate_all()
with self.assertQueryCount(130):
project.write({
'allow_billable': True,
'partner_id': self.partner_b.id,
})
self.assertTrue(project.task_ids.sale_line_id)

View file

@ -144,7 +144,6 @@ class TestProject(TestCommonSaleTimesheet):
'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,
})
@ -165,7 +164,7 @@ class TestProject(TestCommonSaleTimesheet):
self.env['project.task'].create({
'name': 'task A',
'project_id': self.project_global.id,
'planned_hours': 10,
'allocated_hours': 10,
'timesheet_ids': [
Command.create({
'name': '/',
@ -175,12 +174,39 @@ class TestProject(TestCommonSaleTimesheet):
],
})
self.project_global.invalidate_recordset()
self.project_global.account_id.invalidate_recordset()
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'))
form = Form(self.env['product.product'].with_context(
default_type='service',
default_service_policy='delivered_timesheet',
))
self.assertEqual('delivered_timesheet', form.service_policy)
def test_open_product_form_with_default_uom_id(self):
""" Test default product uom fallback when product is not service type """
uom_dozen = self.env.ref('uom.product_uom_dozen')
product_form = Form(self.env['product.product'].with_context(
default_uom_id=uom_dozen.id,
))
self.assertEqual(uom_dozen, product_form.uom_id, "Default uom should be Dozen")
product_form.type = 'service'
product_form.service_policy = 'delivered_timesheet'
uom_hour = self.env.ref('uom.product_uom_hour')
self.assertEqual(
uom_hour,
product_form.uom_id,
"Uom should be updated to Hour for service_type=`timesheet` product"
)
product_form.type = 'consu'
self.assertEqual(
uom_dozen,
product_form.uom_id,
"Uom should be updated to Dozen for `Goods` type product"
)
def test_duplicate_project_allocated_hours(self):
self.project_global.allocated_hours = 10
self.assertEqual(self.project_global.copy().allocated_hours, 10)

View file

@ -2,16 +2,16 @@
# 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
from odoo.tests import Form, tagged
from odoo.addons.mail.tests.common import mail_new_test_user
@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)
def setUpClass(cls):
super().setUpClass()
# set up
cls.employee_tde = cls.env['hr.employee'].create({
@ -27,8 +27,8 @@ class TestProjectBilling(TestCommonSaleTimesheet):
})
# 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)
SaleOrder = cls.env['sale.order']
SaleOrderLine = cls.env['sale.order.line']
cls.sale_order_1 = SaleOrder.create({
'partner_id': cls.partner_a.id,
'partner_invoice_id': cls.partner_a.id,
@ -88,149 +88,17 @@ class TestProjectBilling(TestCommonSaleTimesheet):
'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")
cls.project_sale_manager = mail_new_test_user(
cls.env,
name='Project Manager',
login='project_manager',
email='project_manager@example.com',
groups='project.group_project_manager,sales_team.group_sale_manager',
)
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)
Task = self.env['project.task']
Timesheet = self.env['account.analytic.line']
# create a task
@ -265,14 +133,15 @@ class TestProjectBilling(TestCommonSaleTimesheet):
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({
subtask = Task.create({
'name': 'first subtask task',
'parent_id': task.id,
'project_id': self.project_subtask.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")
self.assertFalse(subtask.partner_id, "Subtask in non billable project should not have a customer")
# log timesheet on subtask
timesheet2 = Timesheet.create({
@ -296,12 +165,12 @@ class TestProjectBilling(TestCommonSaleTimesheet):
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
# but the result differ after the write of 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,
'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")
@ -311,8 +180,9 @@ class TestProjectBilling(TestCommonSaleTimesheet):
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
})
# This needs to be done after creation because setting partner_id causes _get_last_sol_of_customer to recompute the sale_line_id
task2.update({'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({
@ -341,7 +211,7 @@ class TestProjectBilling(TestCommonSaleTimesheet):
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)
Task = self.env['project.task']
Timesheet = self.env['account.analytic.line']
# create a task
@ -367,20 +237,20 @@ class TestProjectBilling(TestCommonSaleTimesheet):
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,
'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")
self.assertFalse(subtask.partner_id, "Subtask should not have the customer if it's project is not billable")
# log timesheet on subtask
timesheet2 = Timesheet.create({
'name': 'Test Line on subtask',
'project_id': subtask.display_project_id.id,
'project_id': subtask.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.assertEqual(subtask.project_id, timesheet2.project_id, "The timesheet is in the subtask project")
self.assertFalse(timesheet2.so_line, "The timesheet should not be linked to SOL as it's a non billable project")
# the `subtask.sale_line_id` is consider to be recompute,
# but the result differ after the write of project_id
@ -391,7 +261,7 @@ class TestProjectBilling(TestCommonSaleTimesheet):
'project_id': self.project_employee_rate.id,
})
subtask.write({
'display_project_id': self.project_employee_rate.id,
'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.")
@ -439,15 +309,15 @@ class TestProjectBilling(TestCommonSaleTimesheet):
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:
with Form(self.env['project.project'].with_user(self.project_sale_manager)) 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
# not allow_billable or not partner_id
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]
@ -463,7 +333,6 @@ class TestProjectBilling(TestCommonSaleTimesheet):
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,

View file

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

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Domain
from odoo.tests import tagged
from .common import TestCommonSaleTimesheet
@ -19,18 +20,23 @@ class TestProjectPricingType(TestCommonSaleTimesheet):
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
def _search_pricing_type(operator, value):
# execute the optimization to transform the domain
return Domain('pricing_type', operator, value).optimize_full(project)
# 1) Take a project non billable and check if the pricing_type is equal to False
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)))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'task_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'employee_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('=', False)))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'task_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'employee_rate')))
self.assertFalse(project.filtered_domain(_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({
@ -42,14 +48,14 @@ class TestProjectPricingType(TestCommonSaleTimesheet):
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)))
self.assertTrue(project.filtered_domain(_search_pricing_type('=', 'task_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'employee_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', False)))
self.assertFalse(project.filtered_domain(_search_pricing_type('!=', 'task_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'employee_rate')))
self.assertTrue(project.filtered_domain(_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({
@ -60,14 +66,14 @@ class TestProjectPricingType(TestCommonSaleTimesheet):
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)))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'task_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'employee_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', False)))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'task_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('!=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'employee_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', False)))
# 4) Set a employee mapping and check if the pricing_type is equal to employee_rate
project.write({
@ -79,14 +85,14 @@ class TestProjectPricingType(TestCommonSaleTimesheet):
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)))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'task_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('=', 'employee_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', False)))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'task_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('!=', 'employee_rate')))
self.assertTrue(project.filtered_domain(_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({
@ -95,11 +101,11 @@ class TestProjectPricingType(TestCommonSaleTimesheet):
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)))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'task_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', 'fixed_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('=', 'employee_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('=', False)))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'task_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', 'fixed_rate')))
self.assertFalse(project.filtered_domain(_search_pricing_type('!=', 'employee_rate')))
self.assertTrue(project.filtered_domain(_search_pricing_type('!=', False)))

View file

@ -9,8 +9,8 @@ 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)
def setUpClass(cls):
super().setUpClass()
cls.task = cls.env['project.task'].create({
'name': 'Test',
'project_id': cls.project_task_rate.id,
@ -19,33 +19,37 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
'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."
)
cls.foreign_currency = cls.env['res.currency'].create({
'name': 'Chaos orb',
'symbol': '',
'rounding': 0.001,
'position': 'after',
'currency_unit_label': 'Chaos',
'currency_subunit_label': 'orb',
})
cls.env['res.currency.rate'].create({
'name': '2016-01-01',
'rate': '5.0',
'currency_id': cls.foreign_currency.id,
'company_id': cls.env.company.id,
})
def test_get_project_profitability_items(self):
""" Test _get_project_profitability_items method to ensure the project profitability
is correctly computed as expected.
is computed as expected.
"""
sale_order = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
foreign_company = self.company_data_2['company']
foreign_company.currency_id = self.foreign_currency
self.project_task_rate.account_id.company_id = False
self.project_task_rate.company_id = False
# Create and confirm a SO with the main company
sale_order = 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,
})
SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True, default_order_id=sale_order.id)
SaleOrderLine = self.env['sale.order.line'].with_context(default_order_id=sale_order.id)
delivery_service_order_line = SaleOrderLine.create({
'product_id': self.product_delivery_manual1.id,
'product_uom_qty': 5,
@ -58,9 +62,102 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
'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.'
)
# Create and confirm a SO with the foreign company
sale_order_foreign = 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,
'company_id': foreign_company.id,
})
sale_order_foreign.currency_id = foreign_company.currency_id
SaleOrderLineForeign = self.env['sale.order.line'].with_context(default_order_id=sale_order_foreign.id)
SaleOrderLineForeign.create({
'product_id': self.product_delivery_manual1.id,
'product_uom_qty': 5,
})
sale_order_foreign.action_confirm()
self.task.write({'sale_line_id': delivery_service_order_line.id})
# Create the foreign users needed for the foreign timesheets
foreign_partner = self.env['res.partner'].create({
'name': 'Foreign Employee address',
'company_id': foreign_company.id,
})
foreign_employee = self.env['hr.employee'].create({
'name': 'test',
'company_id': foreign_company.id,
'work_contact_id': foreign_partner.id,
'hourly_cost': 200,
})
foreign_employee_2 = self.env['hr.employee'].create({
'name': 'test',
'company_id': foreign_company.id,
'work_contact_id': foreign_partner.id,
'hourly_cost': 500,
})
# Create 2 new timesheets linked to the task of the project
Timesheet = self.env['account.analytic.line'].with_context(
default_task_id=self.task.id,
)
foreign_timesheet1 = Timesheet.create({
'name': 'Foreign Timesheet 1',
'employee_id': foreign_employee.id,
'project_id': self.project_task_rate.id,
'unit_amount': 3.0,
'company_id': foreign_company.id,
})
foreign_timesheet2 = Timesheet.create({
'name': 'Foreign Timesheet 2',
'employee_id': foreign_employee.id,
'project_id': self.project_task_rate.id,
'unit_amount': 2.0,
'company_id': foreign_company.id,
})
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((foreign_timesheet1 + foreign_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.')
# Adding an extra cost/revenue to ensure those are computed correctly.
self.env['account.analytic.line'].create([{
'name': 'other revenues line',
'account_id': self.project_task_rate.account_id.id,
'amount': 100,
}, {
'name': 'other costs line',
'account_id': self.project_task_rate.account_id.id,
'amount': -100,
}])
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [{'id': 'other_revenues_aal', 'sequence': sequence_per_invoice_type['other_revenues_aal'], 'invoiced': 100.0, 'to_invoice': 0.0}],
'total': {'invoiced': 100.0, 'to_invoice': 0.0},
},
'costs': {
'data': [
{'id': 'other_costs_aal', 'sequence': sequence_per_invoice_type['other_costs_aal'], 'billed': -100.0, 'to_bill': 0.0},
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed': -100 + (foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2
},
},
}
)
# Create 2 new timesheets linked to the task of the project
timesheet1 = Timesheet.create({
'name': 'Timesheet 1',
'employee_id': self.employee_user.id,
@ -73,40 +170,82 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
'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},
'data': [{'id': 'other_revenues_aal', 'sequence': sequence_per_invoice_type['other_revenues_aal'], 'invoiced': 100.0, 'to_invoice': 0.0}],
'total': {'invoiced': 100.0, 'to_invoice': 0.0},
},
'costs': {
'data': [
{'id': 'other_costs_aal', 'sequence': sequence_per_invoice_type['other_costs_aal'], 'billed': -100.0, 'to_bill': 0.0},
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost,
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2
},
},
}
)
# Create a 3rd foreign timesheet and manually update it.
foreign_timesheet3 = Timesheet.create({
'name': 'Foreign_Timesheet 3',
'employee_id': foreign_employee_2.id,
'project_id': self.project_task_rate.id,
'unit_amount': 1.0,
'so_line': False,
'is_so_line_edited': True,
'company_id': foreign_company.id,
})
self.assertFalse(foreign_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': [{'id': 'other_revenues_aal', 'sequence': sequence_per_invoice_type['other_revenues_aal'], 'invoiced': 100.0, 'to_invoice': 0.0}],
'total': {'invoiced': 100.0, 'to_invoice': 0.0},
},
'costs': {
'data': [
{'id': 'other_costs_aal', 'sequence': sequence_per_invoice_type['other_costs_aal'], 'billed': -100.0, 'to_bill': 0.0},
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2 +
foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2
},
},
}
)
# Create a 3rd timesheet and manually update it.
timesheet3 = Timesheet.create({
'name': 'Timesheet 3',
'employee_id': self.employee_manager.id,
@ -121,106 +260,298 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [],
'total': {'to_invoice': 0.0, 'invoiced': 0.0},
'data': [{'id': 'other_revenues_aal', 'sequence': sequence_per_invoice_type['other_revenues_aal'], 'invoiced': 100.0, 'to_invoice': 0.0}],
'total': {'invoiced': 100.0, 'to_invoice': 0.0},
},
'costs': {
'data': [
{'id': 'other_costs_aal', 'sequence': sequence_per_invoice_type['other_costs_aal'], 'billed': -100.0, 'to_bill': 0.0},
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost,
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'billed': foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + 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,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2 +
foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + 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({
# Create a new foreign sol, and link this sol to the so_line of the task.
foreign_delivery_timesheet_order_line = SaleOrderLineForeign.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.task.write({'sale_line_id': foreign_delivery_timesheet_order_line.id})
billable_timesheets = timesheet1 + timesheet2 + foreign_timesheet1 + foreign_timesheet2
self.assertEqual(billable_timesheets.so_line, foreign_delivery_timesheet_order_line, 'The SOL of the timesheets should be the one of the task.')
self.assertEqual(foreign_delivery_timesheet_order_line.qty_delivered, timesheet1.unit_amount + timesheet2.unit_amount + foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount,
'Since the product type of the SOL is "delivered on TS", the qty_delivered of the SOL should be the total of unit amount of the TS.')
self.assertDictEqual(
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': 'other_revenues_aal', 'sequence': sequence_per_invoice_type['other_revenues_aal'], 'invoiced': 100.0, 'to_invoice': 0.0},
{
'id': 'billable_time',
'sequence': sequence_per_invoice_type['billable_time'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost,
'to_invoice': foreign_delivery_timesheet_order_line.untaxed_amount_to_invoice * 0.2,
'invoiced': 0.0
},
],
'total': {'invoiced': 100.0, 'to_invoice': foreign_delivery_timesheet_order_line.untaxed_amount_to_invoice * 0.2},
},
'costs': {
'data': [
{'id': 'other_costs_aal', 'sequence': sequence_per_invoice_type['other_costs_aal'], 'billed': -100.0, 'to_bill': 0.0},
{
'id': 'billable_time',
'sequence': sequence_per_invoice_type['billable_time'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'billed': foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + 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,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2 +
foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + timesheet3.unit_amount * -self.employee_manager.hourly_cost
},
},
},
}
)
milestone_order_line = SaleOrderLine.create({
'product_id': self.product_milestone.id,
'product_uom_qty': 1,
# Create a new task in the project, link to it a new SO form the main company SO with a delivery timesheet product.
delivery_timesheet_order_line = SaleOrderLine.create({
'product_id': self.product_delivery_timesheet1.id,
'product_uom_qty': 5,
})
task2 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Test',
task_2 = self.env['project.task'].create({
'name': 'Task 2',
'project_id': self.project_task_rate.id,
'sale_line_id': milestone_order_line.id,
'sale_line_id': delivery_timesheet_order_line.id,
})
task2_timesheet = Timesheet.with_context(default_task_id=task2.id).create({
task2_timesheet = Timesheet.with_context(default_task_id=task_2.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)
self.assertNotEqual(delivery_timesheet_order_line.untaxed_amount_to_invoice, 0.0)
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [
{'id': 'other_revenues_aal', 'sequence': sequence_per_invoice_type['other_revenues_aal'],
'invoiced': 100.0, 'to_invoice': 0.0},
{
'id': 'billable_time',
'sequence': sequence_per_invoice_type['billable_time'],
'to_invoice': delivery_timesheet_order_line.untaxed_amount_to_invoice + foreign_delivery_timesheet_order_line.untaxed_amount_to_invoice * 0.2,
'invoiced': 0.0
},
],
'total': {'invoiced': 100.0, 'to_invoice': foreign_delivery_timesheet_order_line.untaxed_amount_to_invoice * 0.2 + delivery_timesheet_order_line.untaxed_amount_to_invoice},
},
'costs': {
'data': [
{'id': 'other_costs_aal', 'sequence': sequence_per_invoice_type['other_costs_aal'], 'billed': -100.0,
'to_bill': 0.0},
{
'id': 'billable_time',
'sequence': sequence_per_invoice_type['billable_time'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount + task2_timesheet.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount + task2_timesheet.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2 +
foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + timesheet3.unit_amount * -self.employee_manager.hourly_cost
},
},
}
)
# Create a SOL in the foreign SO with a milestone service product.
milestone_foreign_order_line = SaleOrderLineForeign.create({
'product_id': self.product_milestone.id,
'product_uom_qty': 1,
})
task2_foreign = self.env['project.task'].create({
'name': 'Test',
'project_id': self.project_task_rate.id,
'sale_line_id': milestone_foreign_order_line.id,
})
task2_foreign_timesheet = Timesheet.with_context(default_task_id=task2_foreign.id).create({
'name': '/',
'project_id': self.project_task_rate.id,
'employee_id': foreign_employee.id,
'unit_amount': 1,
})
self.assertEqual(task2_foreign_timesheet.so_line, milestone_foreign_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},
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'], 'to_bill': 0.0, 'billed': task2_foreign_timesheet.amount * 0.2},
)
milestone_foreign_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_foreign_order_line.untaxed_amount_to_invoice * 0.2, 'invoiced': 0.0},
)
# Create a second timesheet in the new task, with an employee from the main company.
task2_timesheet = Timesheet.with_context(default_task_id=task2_foreign.id).create({
'name': '/',
'project_id': self.project_task_rate.id,
'employee_id': self.employee_user.id,
'unit_amount': 1,
})
profitability_items = self.project_task_rate._get_profitability_items(False)
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 + task2_foreign_timesheet.amount * 0.2},
)
milestone_foreign_order_line.qty_delivered = 2
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_foreign_order_line.untaxed_amount_to_invoice * 0.2, 'invoiced': 0.0},
)
# Create a SOL in the foreign SO with a milestone service product.
milestone_order_line = SaleOrderLine.create({
'product_id': self.product_milestone.id,
'product_uom_qty': 1,
})
task3_milestone = self.env['project.task'].create({
'name': 'Task 3',
'project_id': self.project_task_rate.id,
'sale_line_id': milestone_order_line.id,
})
task3_timesheet = Timesheet.with_context(default_task_id=task3_milestone.id).create({
'name': '/',
'project_id': self.project_task_rate.id,
'employee_id': self.employee_user.id,
'unit_amount': 1,
})
profitability_items = self.project_task_rate._get_profitability_items(False)
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 + task2_foreign_timesheet.amount * 0.2 + task3_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},
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'],
'to_invoice': milestone_foreign_order_line.untaxed_amount_to_invoice * 0.2 + milestone_order_line.untaxed_amount_to_invoice, 'invoiced': 0.0},
)
# Cancel the milestone timesheets
task2_timesheet.unlink()
task2_foreign_timesheet.unlink()
task3_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'])
def test_profitability_revenue(self):
product_profitability_items = self.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': self.uom_hour.id,
'default_code': 'SERV-ORDERED3',
'service_tracking': 'task_global_project',
'project_id': self.project_task_rate.id,
'service_type': 'manual',
})
saleorder_revenue = self.env['sale.order'].with_context(tracking_disable=True)
saleOrderLine_revenue = self.env['sale.order.line'].with_context(tracking_disable=True)
sale_order_revenue = saleorder_revenue.create({
'partner_id': self.partner_b.id,
'partner_invoice_id': self.partner_b.id,
'partner_shipping_id': self.partner_b.id,
})
sale_order_line_revenue = saleOrderLine_revenue.create({
'product_id': product_profitability_items.id,
'product_uom_qty': 10,
'order_id': sale_order_revenue.id,
})
sequence_per_invoice_type = self.project_task_rate._get_profitability_sequence_per_invoice_type()
profitability_item_data = {
'revenues': {
'data': [{
'id': 'billable_fixed',
'sequence': sequence_per_invoice_type['billable_fixed'],
'invoiced': 0.0,
'to_invoice': 200.0
}],
'total': {
'invoiced': 0.0,
'to_invoice': 200.0
}
},
'costs': {
'data': [],
'total': {'billed': 0.0, 'to_bill': 0.0}
}
}
sale_order_revenue.action_confirm()
project_profitability_items = sale_order_line_revenue.project_id
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
profitability_item_data
)
project_profitability_items.active = False
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
profitability_item_data
)
profitability_before = self.project_task_rate.get_panel_data()['profitability_items']
product_profitability_items.active = False
profitability_after = self.project_task_rate.get_panel_data()['profitability_items']
self.assertEqual(
profitability_before,
profitability_after,
)

View file

@ -16,13 +16,10 @@ class TestProjectUpdateSaleTimesheet(TestProjectUpdate):
# 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")
self.assertEqual(template_values['profitability']['total']['costs'], 0.0, "Project costs used in the template should be well defined")
self.assertEqual(template_values['profitability']['total']['revenues'], 0.0, "Project revenues used in the template should be well defined")
self.assertEqual(template_values['profitability']['total']['margin'], 0, "Margin used in the template should be well defined")
self.assertEqual(template_values['profitability']['total']['margin_percentage'], "0", "Margin percentage used in the template should be well defined")
def test_project_update_panel_profitability_no_billable(self):
try:

View file

@ -12,8 +12,8 @@ from odoo.tests import Form, tagged
class TestReInvoice(TestCommonSaleTimesheet):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
# patch expense products to make them services creating task/project
service_values = {
@ -31,8 +31,7 @@ class TestReInvoice(TestCommonSaleTimesheet):
# create AA, SO and invoices
cls.analytic_plan = cls.env['account.analytic.plan'].create({
'name': 'Plan',
'company_id': cls.company_data['company'].id,
'name': 'Timesheet Plan',
})
cls.analytic_account = cls.env['account.analytic.account'].create({
@ -43,34 +42,39 @@ class TestReInvoice(TestCommonSaleTimesheet):
'partner_id': cls.partner_a.id
})
cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
cls.project = cls.env['project.project'].create({
'name': 'SO Project',
'allow_timesheets': False,
f'{cls.analytic_plan._column_name()}': cls.analytic_account.id,
})
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,
'analytic_account_id': cls.analytic_account.id,
'pricelist_id': cls.company_data['default_pricelist'].id,
'project_id': cls.project.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')
# Required for `analytic_distribution` to be visible in the view
self.env.user.group_ids += 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,
'product_uom_id': self.env.ref('uom.product_uom_hour').id,
'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,
'product_uom_id': self.env.ref('uom.product_uom_hour').id,
'order_id': self.sale_order.id,
})
@ -116,10 +120,10 @@ class TestReInvoice(TestCommonSaleTimesheet):
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(len(self.sale_order.order_line.mapped('project_id')), 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.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, 3.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, 3.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")
@ -147,25 +151,27 @@ class TestReInvoice(TestCommonSaleTimesheet):
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')
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, 2.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, 2.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')
# Required for `analytic_distribution` to be visible in the view
self.env.user.group_ids += 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,
'product_uom_id': self.env.ref('uom.product_uom_hour').id,
'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,
'product_uom_id': self.env.ref('uom.product_uom_hour').id,
'qty_delivered': 1,
'order_id': self.sale_order.id,
})
@ -208,10 +214,10 @@ class TestReInvoice(TestCommonSaleTimesheet):
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(len(self.sale_order.order_line.mapped('project_id')), 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.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, 3.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, 3.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")
@ -239,16 +245,17 @@ class TestReInvoice(TestCommonSaleTimesheet):
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')
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, 3.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')
# Required for `analytic_distribution` to be visible in the view
self.env.user.group_ids += 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,
'product_uom_id': self.env.ref('uom.product_uom_hour').id,
'qty_delivered': 1,
'order_id': self.sale_order.id,
})
@ -334,7 +341,6 @@ class TestReInvoice(TestCommonSaleTimesheet):
"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({
@ -353,10 +359,9 @@ class TestReInvoice(TestCommonSaleTimesheet):
}
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 = self.env['account.move'].browse(refund_invoice_wiz.refund_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
@ -369,3 +374,47 @@ class TestReInvoice(TestCommonSaleTimesheet):
# The actual test :
wizard.create_invoices() # No exception should be raised, there is indeed something to be invoiced since it was reversed
def test_project_update_reinvoiced_vendor_bill_product(self):
project_product, expense_product = self.env['product.product'].create([{
'name': 'Project creation',
'type': 'service',
'service_tracking': 'task_in_project',
}, {
'name': 'Expense Product',
'expense_policy': 'sales_price',
'list_price': 20,
}])
sale_order = self.env['sale.order'].create({'partner_id': self.partner_a.id})
self.env['sale.order.line'].create({
'product_id': project_product.id,
'order_id': sale_order.id,
})
sale_order.action_confirm()
project = sale_order.project_ids
self.assertTrue(project, 'Project should have been created on sale order confirmation')
vendor_bill = self.env['account.move'].create({
'partner_id': self.partner_a.id,
'invoice_date': sale_order.date_order,
'journal_id': self.env['account.journal'].search([('code', '=', 'BILL'), ('company_id', '=', self.env.company.id)]).id,
'move_type': 'in_invoice',
})
self.env['account.move.line'].create({
'product_id': expense_product.id,
'move_id': vendor_bill.id,
'account_id': self.env['account.account'].search([('code', '=', '600000')]).id,
'analytic_distribution': {project.account_id.id: 100},
'price_unit': 20,
})
vendor_bill.action_post() # An analytic line is created for the vendor bill move line
self.assertEqual(project.account_id.vendor_bill_count, 1, 'Vendor bill should be linked to project account')
self.assertTrue(vendor_bill.line_ids.analytic_line_ids, 'Analytic line should be created for the account move line')
self.assertTrue(sale_order.order_line.analytic_line_ids, 'Analytic line should be linked to the sale order line created by the re-invoiced expense product')
# Only the original vendor bill amount should appear on the project update, to stay consistent with the corresponding hr_expense behavior
updates = project._get_profitability_items()
data_line = updates['costs']['data'][0]
self.assertEqual(data_line['id'], 'other_purchase_costs')
self.assertEqual(data_line['billed'], -20)
self.assertEqual(updates['costs']['total']['billed'], -20, 'Only the vendor bill should be deducted')

View file

@ -10,10 +10,10 @@ 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)
def setUpClass(cls):
super().setUpClass()
cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
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,
@ -38,7 +38,6 @@ class TestSaleService(TestCommonSaleTimesheet):
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({
@ -63,7 +62,6 @@ class TestSaleService(TestCommonSaleTimesheet):
'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',
@ -93,7 +91,7 @@ class TestSaleService(TestCommonSaleTimesheet):
'order_id': self.sale_order.id,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 5,
'product_uom': uom_days.id,
'product_uom_id': uom_days.id,
})
self.sale_order.action_confirm()
task = self.env['project.task'].search([('sale_line_id', '=', sale_order_line.id)])
@ -223,10 +221,11 @@ class TestSaleService(TestCommonSaleTimesheet):
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")
self.assertEqual(so_line1.product_uom_qty, so_line1.project_id.allocated_hours, "The planned hours on the project should be the same as the ordered quantity of the native SO line")
self.assertEqual(so_line1.product_uom_qty, so_line1.task_id.allocated_hours, "The planned hours on the task 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")
self.assertEqual(so_line1.product_uom_qty, so_line1.task_id.allocated_hours, "The planned hours should have changed when updating the ordered quantity of the native SO line")
# cancel SO
self.sale_order._action_cancel()
@ -237,7 +236,7 @@ class TestSaleService(TestCommonSaleTimesheet):
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")
self.assertEqual(so_line1.product_uom_qty, so_line1.task_id.allocated_hours, "The planned hours should have changed when updating the ordered quantity, even after SO cancellation")
# reconfirm SO
self.sale_order.action_draft()
@ -248,7 +247,7 @@ class TestSaleService(TestCommonSaleTimesheet):
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()
self.sale_order.action_lock()
with self.assertRaises(UserError):
so_line1.write({'product_uom_qty': 20})
@ -282,7 +281,6 @@ class TestSaleService(TestCommonSaleTimesheet):
'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',
@ -352,60 +350,6 @@ class TestSaleService(TestCommonSaleTimesheet):
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).
@ -478,7 +422,7 @@ class TestSaleService(TestCommonSaleTimesheet):
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})
subtask.write({'project_id': self.project_global.id})
self.assertEqual(subtask.sale_line_id, task.sale_line_id, "A child task should always have the same SO line as its mother, even when changing project")
self.assertEqual(subtask.sale_line_id, so_line_deliver_new_task_project)
@ -503,16 +447,16 @@ class TestSaleService(TestCommonSaleTimesheet):
})
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")
self.assertEqual(sale_order_line.product_uom_qty, sale_order_line.task_id.allocated_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.assertEqual(sale_order_line.product_uom_qty, sale_order_line.task_id.allocated_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.assertEqual(sale_order_line.product_uom_qty, sale_order_line.task_id.allocated_hours, "The planned hours should have changed when updating the ordered quantity, even after SO cancellation")
self.sale_order.action_done()
self.sale_order.action_lock()
with self.assertRaises(UserError):
sale_order_line.write({'product_uom_qty': 20})
@ -596,11 +540,10 @@ class TestSaleService(TestCommonSaleTimesheet):
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 = {
allocated_hours_for_uom = {
'day': 8.0,
'hour': 1.0,
'unit': 1.0,
'gram': 0.0,
}
project = self.project_global.copy({'tasks': False})
@ -619,20 +562,18 @@ class TestSaleService(TestCommonSaleTimesheet):
'order_id': self.sale_order.id,
}
for uom_name in planned_hours_for_uom:
for uom_name in allocated_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,
'product_uom_id': uom_id.id,
})
SaleOrderLine.create(sol_vals)
@ -640,14 +581,12 @@ class TestSaleService(TestCommonSaleTimesheet):
tasks = project.task_ids
for task in tasks:
self.assertEqual(task.planned_hours, planned_hours_for_uom[task.sale_line_id.name])
self.assertEqual(task.allocated_hours, allocated_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']
@ -664,7 +603,6 @@ class TestSaleService(TestCommonSaleTimesheet):
'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()
@ -676,7 +614,6 @@ class TestSaleService(TestCommonSaleTimesheet):
'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,
})
@ -684,7 +621,7 @@ class TestSaleService(TestCommonSaleTimesheet):
# 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},
self.assertEqual(invoice_line.analytic_distribution, {str(self.project_global.account_id.id): 100},
"SOL's analytic distribution should contain the project analytic account")
def test_sale_timesheet_invoice(self):
@ -750,12 +687,10 @@ class TestSaleService(TestCommonSaleTimesheet):
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
1) Create a 2 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
@ -765,26 +700,16 @@ class TestSaleService(TestCommonSaleTimesheet):
'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
'product_uom_id': 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
'product_uom_id': self.env.ref('uom.product_uom_hour').id, # 8 hours
}])
self.sale_order.action_confirm()
allocated_hours = self.sale_order.project_ids.allocated_hours
self.assertEqual(16 + 8 + 6, allocated_hours,
self.assertEqual(16 + 8, allocated_hours,
"Project's allocated hours should add up correctly.")
self.env.company.timesheet_encode_uom_id = self.env.ref('uom.product_uom_day')
@ -793,16 +718,103 @@ class TestSaleService(TestCommonSaleTimesheet):
self.assertEqual(allocated_hours, so_copy.project_ids.allocated_hours,
"Timesheet encoding shouldn't affect hours allocated.")
def test_compute_project_and_task_button_with_ts(self):
""" This test ensures that the button are correctly computed when there is a timesheet service product on a SO. The behavior was not modified in sale_timesheet, but since
the timesheet product case can not be tested in sale_project, we have to add the test here."""
sale_order_1 = 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,
}])
# delivered timesheet
line_1 = self.env['sale.order.line'].create({
'product_id': self.product_service_delivered_timesheet.id,
'order_id': sale_order_1.id,
})
sale_order_1.action_confirm()
self.assertTrue(sale_order_1.show_create_project_button, "There is a product service with the service_policy set on 'delivered on timesheet' on the sale order, the button should be displayed")
self.assertFalse(sale_order_1.show_project_button, "There is no project on the sale order, the button should be hidden")
line_1.project_id = self.project_global.id
sale_order_1._compute_show_project_and_task_button()
self.assertFalse(sale_order_1.show_create_project_button, "There is a product service with the service_policy set on 'delivered on timesheet' and a project on the sale order, the button should be hidden")
self.assertTrue(sale_order_1.show_project_button, "There is a product service with the service_policy set on 'delivered on timesheet' and a project on the sale order, the button should be displayed")
def test_compute_show_timesheet_button(self):
""" This test ensures that the hours recorded button is correctly computed. If there is a service product with an invoice policy of prepaid or timesheet, and there is
at least on project linked to the SO, then the button should be displayed """
sale_order_1, sale_order_2 = 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,
}, {
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
}])
# consumable product, delivered milestone, and delivered manual
self.env['sale.order.line'].create([{
'product_id': self.product_consumable.id,
'order_id': sale_order_1.id,
}, {
'product_id': self.product_service_delivered_milestone.id,
'order_id': sale_order_1.id,
}, {
'product_id': self.product_service_delivered_manual.id,
'order_id': sale_order_1.id,
}])
sale_order_1.action_confirm()
self.assertFalse(sale_order_1.show_hours_recorded_button, "There is no service product service with the correct service_policy set on 'delivered on timesheet' on the sale order, the button should be hidden")
# adds a delivered timesheet product to the SO
line_4 = self.env['sale.order.line'].create({
'product_id': self.product_service_delivered_timesheet.id,
'order_id': sale_order_1.id,
})
self.assertFalse(sale_order_1.show_hours_recorded_button, "There is a product service with the service_policy set on 'delivered on timesheet' but no project on the sale order, the button should be hidden")
line_4.project_id = self.project_global
sale_order_1._compute_show_hours_recorded_button()
self.assertTrue(sale_order_1.show_hours_recorded_button, "There is a product service with the service_policy set on 'delivered on timesheet' and a project on the sale order, the button should be displayed")
line_1 = self.env['sale.order.line'].create({
'product_id': self.product_service_ordered_prepaid.id,
'order_id': sale_order_2.id,
})
sale_order_2.action_confirm()
self.assertFalse(sale_order_2.show_hours_recorded_button, "There is a product service with the service_policy set on 'delivered on timesheet' but no project on the sale order, the button should be hidden")
line_1.project_id = self.project_global
sale_order_2._compute_show_hours_recorded_button()
self.assertTrue(sale_order_2.show_hours_recorded_button, "There is a product service with the service_policy set on 'delivered on timesheet' and a project on the sale order, the button should be displayed")
# remove the project from the so and ensure the SO is back to its previous state
line_1.project_id = False
sale_order_2._compute_show_hours_recorded_button()
self.assertFalse(sale_order_2.show_hours_recorded_button, "There is a product service with the service_policy set on 'delivered on timesheet' but no project on the sale order, the button should be hidden")
# adds a task whose sale item is a sale order line from the SO, and adds a timesheet in that task. This should enable the display of the button
task = self.env['project.task'].create({
'name': 'Test Task',
'project_id': self.project_global.id,
'sale_line_id': line_1.id,
})
self.env['account.analytic.line'].create({
'name': "timesheet",
'unit_amount': 5,
'project_id': task.project_id.id,
'task_id': task.id,
'employee_id': self.employee_user.id,
})
sale_order_2._compute_timesheet_count()
sale_order_2._compute_show_hours_recorded_button()
self.assertTrue(sale_order_2.show_hours_recorded_button, "There is a product service with the service_policy set on 'delivered on timesheet' and a project on the sale order, the button should be displayed")
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['decimal.precision'].search([('name', '=', 'Product Unit')]).digits = 0
self.product_delivery_timesheet3.uom_id._invalidate_cache(['rounding'])
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,
})
@ -833,3 +845,38 @@ class TestSaleService(TestCommonSaleTimesheet):
hours_delivered,
f"{amount} hours delivered should round the same for invoice & timesheet",
)
def test_prepaid_pack_remaining_hours_rounding(self):
"""Avoid double rounding with pack UoM"""
uom_hour = self.env.ref('uom.product_uom_hour')
pack20 = self.env['uom.uom'].create({
'name': 'Pack of 20 Hours',
'relative_factor': 20.0,
'relative_uom_id': uom_hour.id,
})
product = self.env['product.product'].create({
'name': 'Prepaid Pack 20h',
'type': 'service',
'uom_id': pack20.id,
'service_type': 'timesheet',
'service_policy': 'ordered_prepaid',
'service_tracking': 'task_in_project',
})
order = self.env['sale.order'].create({'partner_id': self.partner_a.id})
sol = self.env['sale.order.line'].create({
'order_id': order.id,
'product_id': product.id,
'product_uom_qty': 1.0,
'product_uom_id': pack20.id,
})
order.action_confirm()
self.env['account.analytic.line'].create({
'name': 'Over-consumed timesheet',
'project_id': sol.project_id.id,
'task_id': sol.task_id.id,
'unit_amount': 22.0,
'employee_id': self.employee_user.id,
})
sol.invalidate_recordset()
self.assertAlmostEqual(sol.remaining_hours, -2.0, places=6)
self.assertIn('-02:00', sol.with_context(with_remaining_hours=True).display_name)

View file

@ -1,14 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date, timedelta
from odoo import Command
from odoo.fields import Date
from odoo.tools import float_is_zero
from odoo.exceptions import UserError
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.addons.hr_timesheet.tests.test_timesheet import TestCommonTimesheet
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet
from odoo.tests import tagged
from odoo.tests import Form, tagged, new_test_user
@tagged('-at_install', 'post_install')
class TestSaleTimesheet(TestCommonSaleTimesheet):
@ -20,6 +19,45 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
quantities changes, ...
"""
def test_compute_commercial_partner(self):
"""Ensure user without project access can compute commercial partner without AccessError.
Steps:
1. Create a commercial partner and a sub-partner.
2. Create a project assigned to the sub-partner and a task under that project. Link both to a timesheet.
3. Create a restricted user with no access to the Project module but with Timesheet Administrator access.
4. Compute the commercial partner as the restricted user and verify it's derived from the project partner.
5. Set the task partner, recompute, and verify the commercial partner updates accordingly.
"""
commercial_partner = self.env['res.partner'].create({'name': 'Commercial Partner', 'is_company': True})
sub_partner = self.env['res.partner'].create({'name': 'Sub Partner', 'parent_id': commercial_partner.id})
project = self.env['project.project'].create({
'name': 'Test Project',
'partner_id': sub_partner.id,
'privacy_visibility': 'followers',
'task_ids': [Command.create({'name': 'Test Task'})]
})
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Timesheet',
'project_id': project.id,
'task_id': project.task_ids[0].id,
'employee_id': self.employee_user.id,
})
timesheet_manager_no_project_user = new_test_user(self.env, login='no_project_user', groups='hr_timesheet.group_timesheet_manager')
timesheet.with_user(timesheet_manager_no_project_user)._compute_commercial_partner()
self.assertEqual(
timesheet.commercial_partner_id,
commercial_partner,
"The commercial partner should match the partner linked to the project."
)
project.task_ids[0].partner_id = sub_partner.id
timesheet.with_user(timesheet_manager_no_project_user)._compute_commercial_partner()
self.assertEqual(
timesheet.commercial_partner_id,
commercial_partner,
"The commercial partner should match the partner linked to the task."
)
def test_timesheet_order(self):
""" Test timesheet invoicing with 'invoice on order' timetracked products
1. create SO with 2 ordered product and confirm
@ -33,7 +71,6 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'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,
@ -51,7 +88,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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")
self.assertEqual(sale_order.project_account_id, project_serv1.account_id, "The created project should be linked to the analytic account of the SO")
# create invoice
invoice1 = sale_order._create_invoices()[0]
@ -160,7 +197,6 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'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,
@ -183,10 +219,10 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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.project_account_id, task_serv2.project_id.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")
self.assertEqual(sale_order.project_account_id, project_serv2.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({
@ -283,7 +319,6 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'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,
@ -303,7 +338,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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")
self.assertEqual(sale_order.project_account_id, project_serv2.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({
@ -364,7 +399,6 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'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({
@ -382,6 +416,11 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'product_uom_qty': 20,
'order_id': sale_order.id,
})
so_line_deliver_timesheet = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet1.id,
'product_uom_qty': 5,
'order_id': sale_order.id,
})
# confirm SO
sale_order.action_confirm()
@ -423,6 +462,22 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'unit_amount': 30,
'employee_id': self.employee_manager.id
})
with self.assertRaises(AccessError, msg="The user should not have access to the SOL"):
so_line_deliver_timesheet.with_user(self.user_employee_without_sales_access).read(['name'])
# invalidate cache to make sure the SOL set on the timesheet is not in the cache since the user
# should not be able to access on the SOL.
self.env['sale.order.line'].invalidate_model()
timesheet5 = self.env['account.analytic.line'].with_user(self.user_employee_without_sales_access).create({
'name': 'Test Line 5',
'project_id': task_serv2.project_id.id,
'task_id': task_serv2.id,
'unit_amount': 10,
'employee_id': self.employee_without_sales_access.id,
'so_line': so_line_deliver_timesheet.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')
@ -501,7 +556,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
task = Task.with_context(default_project_id=self.project_template.id).create({
'name': 'first task',
'partner_id': self.partner_b.id,
'planned_hours': 10,
'allocated_hours': 10,
'sale_line_id': self.so.order_line[0].id
})
@ -518,7 +573,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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
# change project of task, non-validated timesheets will follow the project of task
task.write({
'project_id': self.project_global.id
})
@ -552,14 +607,14 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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.
# change project of task, only the timesheet not billed gets its project changed
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_count1, 1, "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")
@ -568,13 +623,11 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'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,
@ -632,7 +685,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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({
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,
@ -643,7 +696,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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,
'product_uom_id': uom_days.id,
})
sale_order.action_confirm()
task = sale_order_line.task_id
@ -670,7 +723,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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'),
('subject', 'like', 'To-Do'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
@ -687,7 +740,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
message_sent = self.env['mail.message'].search([
('id', '>', last_message_id),
('subject', 'like', 'Upsell'),
('subject', 'like', 'To-Do'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
@ -696,7 +749,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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({
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,
@ -707,7 +760,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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,
'product_uom_id': uom_days.id,
})
sale_order.action_confirm()
task = sale_order_line.task_id
@ -734,7 +787,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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'),
('subject', 'like', 'To-Do'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
@ -751,7 +804,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
message_sent = self.env['mail.message'].search([
('id', '>', last_message_id),
('subject', 'like', 'Upsell'),
('subject', 'like', 'To-Do'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
@ -783,7 +836,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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'),
('subject', 'like', 'To-Do'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
@ -800,7 +853,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
message_sent = self.env['mail.message'].search([
('id', '>', last_message_id),
('subject', 'like', 'Upsell'),
('subject', 'like', 'To-Do'),
('model', '=', 'sale.order'),
('res_id', '=', sale_order.id),
])
@ -811,13 +864,11 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'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,
})
@ -839,9 +890,37 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
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_update_sol_price(self):
""" This test ensure that when the price of a sol is updated, the project_profitability panel from the project linked to the SO of that sol is correctly updated too.
1) create new SO
2) add a sol with a service product with 'invoice on prepaid' and 'create project & task' setting.
3) confirm SO and check the project_profitability panel
4) update the price of the sol and check the project_profitability panel
"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
})
product_price = self.product_order_timesheet3.list_price
so_line = self.env['sale.order.line'].create({
'name': self.product_order_timesheet3.name,
'product_id': self.product_order_timesheet3.id,
'product_uom_qty': 1,
'price_unit': product_price,
'order_id': sale_order.id,
})
sale_order.action_confirm()
project = sale_order.project_ids[0]
items = project._get_profitability_items(with_action=False)
self.assertEqual(items['revenues']['data'][0]['to_invoice'], product_price, "The quantity to_invoice should be equal to the price of the product")
so_line.price_unit = 2*product_price
items = project._get_profitability_items(with_action=False)
self.assertEqual(items['revenues']['data'][0]['to_invoice'], 2*product_price, "The quantity to_invoice should be equal to twice the price of the product")
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({
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,
@ -860,7 +939,6 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'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,
@ -872,7 +950,6 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'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,
@ -886,14 +963,12 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
'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,
},
])
@ -902,10 +977,349 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
self.assertEqual(10, sale_order_line_template_1.project_id.allocated_hours)
self.assertEqual(5, sale_order_line_template_2.project_id.allocated_hours)
def test_onchange_uom_service_product(self):
uom_unit = self.env.ref('uom.product_uom_unit')
uom_kg = self.env.ref('uom.product_uom_kgm')
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']),
# Create product (consumable that will be switch to service)
product_1 = self.env['product.template'].create([
{
'name': "Consumable to convert to service 1",
'standard_price': 10,
},
])
product_2 = self.env['product.product'].create({
'name': "Consumable to convert to service 2",
'standard_price': 15,
})
# Initial uom should be unit
self.assertEqual([product_1.uom_id.id, product_2.uom_id.id], [uom_unit.id]*2)
products = [product_1, product_2] #perform the tests for both product and variants
for product in products:
# 1. product.template form: [uom: unit] --> change to service --> [uom: hour]
with Form(product, view="sale_timesheet.view_product_timesheet_form") as product_form:
product_form.type = 'service'
product_form.service_policy = 'delivered_timesheet'
self.assertEqual(product_form.uom_id.id, self.uom_hour.id)
# 2. product.template form: [uom: kgm] --> change to service --> [uom: hour] --> change to consumable --> [uom: kgm]
product.write({
'type': 'consu',
'uom_id': uom_kg.id,
})
with Form(product, view="sale_timesheet.view_product_timesheet_form") as product_form:
product_form.type = 'service'
product_form.service_policy = 'delivered_timesheet'
self.assertEqual(product_form.uom_id.id, self.uom_hour.id)
product_form.type = 'consu'
self.assertEqual(product_form.uom_id.id, uom_kg.id)
def test_allocated_hours_copy(self):
""" This test ensures that the generated project's allocated_hours field is copied from the project template when it is set."""
project_template = self.env['project.project'].create({
'name': 'Template',
'allocated_hours': 65,
})
product = self.env['product.product'].create({
'name': "Service with template",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'order',
'uom_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.id,
})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
})
sale_order_line = self.env['sale.order.line'].create({
'order_id': sale_order.id,
'name': product.name,
'product_id': product.id,
'product_uom_qty': 10,
'price_unit': product.list_price,
})
project = sale_order_line._timesheet_create_project()
self.assertTrue(
project.allocated_hours == project_template.allocated_hours != sale_order_line.product_uom_qty,
"The project's allocated hours should have been copied from its template, rather than the sale order line",
)
def test_non_consolidated_billing_service_timesheet(self):
"""
When consolidated_billing is set to False, an invoice is created for each sale order
Makes sure it works with sales orders linked to timesheets
"""
sale_orders = self.env['sale.order'].create([{
'partner_id': self.partner_a.id,
'order_line': [Command.create({
'product_id': self.product_delivery_timesheet2.id,
})],
}, {
'partner_id': self.partner_a.id,
'order_line': [Command.create({
'product_id': self.product_delivery_timesheet2.id,
})],
}])
sale_orders.action_confirm()
self.env['account.analytic.line'].create([{
'name': 'Timesheet',
'task_id': task.id,
'project_id': task.project_id.id,
'unit_amount': 2,
'employee_id': self.employee_user.id,
} for task in sale_orders.tasks_ids])
advance_payment = self.env['sale.advance.payment.inv'].with_context(active_ids=sale_orders.ids).create({
'consolidated_billing': False,
})
invoices = advance_payment._create_invoices(sale_orders)
self.assertEqual(len(invoices), 2, "The number of invoices created should be equal to the number of sales orders.")
def test_timesheet_with_negative_time_spent(self):
""" Check the billable type of a timesheet with negative time spent """
sale_order = self.env['sale.order'].create([{
'partner_id': self.partner_a.id,
'order_line': [Command.create({
'product_id': self.product_delivery_timesheet2.id,
})],
}])
sale_order.action_confirm()
task1 = sale_order.tasks_ids
timesheet = self.env['account.analytic.line'].create([
{
'name': 'Timesheet',
'task_id': task1.id,
'project_id': task1.project_id.id,
'unit_amount': -1,
'employee_id': self.employee_user.id,
},
])
self.assertEqual(timesheet.timesheet_invoice_type, 'billable_time')
def test_linked_timesheet_after_invoice_reversal(self):
"""Test that uneditable timesheet entries aren't linked to a reversed invoice form"""
# Full refund credit note
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,
})
so_line = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 1,
'order_id': sale_order.id,
})
sale_order.action_confirm()
task = so_line.task_id
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Invoice Reversal',
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
invoice = sale_order._create_invoices()[0]
invoice.action_post()
self.assertEqual(timesheet.timesheet_invoice_id, invoice, "Timesheet should be linked to the invoice")
reversal_wizard = self.env['account.move.reversal'].with_context(
active_model='account.move',
active_ids=invoice.ids
).create({
'reason': 'full refund',
'journal_id': invoice.journal_id.id,
})
reversal_wizard.modify_moves()
self.assertFalse(timesheet.timesheet_invoice_id, "Timesheet should not be linked to the invoice after reversal")
timesheet.write({'unit_amount': 7})
self.assertEqual(timesheet.unit_amount, 7, "It Should be possible to edit timesheet after invoice reversal")
# Partial refund credit note
sale_order2 = 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,
})
so_line1 = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 1,
'order_id': sale_order2.id,
})
so_line2 = self.env['sale.order.line'].create({
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 1,
'order_id': sale_order2.id,
})
sale_order2.action_confirm()
task1 = so_line1.task_id
task2 = so_line2.task_id
timesheet1 = self.env['account.analytic.line'].create({
'name': 'Timesheet Task 1',
'project_id': task1.project_id.id,
'task_id': task1.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
timesheet2 = self.env['account.analytic.line'].create({
'name': 'Timesheet Task 2',
'project_id': task2.project_id.id,
'task_id': task2.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
invoice2 = sale_order2._create_invoices()[0]
invoice2.action_post()
self.assertEqual(timesheet1.timesheet_invoice_id, invoice2, "Timesheet1 should be linked to the invoice")
self.assertEqual(timesheet2.timesheet_invoice_id, invoice2, "Timesheet2 should be linked to the invoice")
refund_wizard = self.env['account.move.reversal'].with_context(
active_model='account.move',
active_ids=invoice2.ids
).create({
'reason': 'partial refund',
'journal_id': invoice2.journal_id.id,
})
refund_action = refund_wizard.refund_moves()
credit_note = self.env['account.move'].browse(refund_action['res_id'])
invoice_line_to_remove = credit_note.invoice_line_ids.filtered(
lambda line: line.sale_line_ids.id == so_line2.id
)
invoice_line_to_remove.unlink()
credit_note.action_post()
self.assertFalse(timesheet1.timesheet_invoice_id, "Timesheet1 should be cleared after partial refund of its task")
self.assertEqual(timesheet2.timesheet_invoice_id, invoice2, "Timesheet2 should still be linked to the original invoice")
@tagged('-at_install', 'post_install')
class TestSaleTimesheetAnalyticPlan(TestCommonSaleTimesheet):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.plan_b, cls.plan_c = cls.env['account.analytic.plan'].create([
{'name': 'plan 2'},
{'name': 'plan 3'},
])
def test_timesheet_get_accounts_from_sol(self):
project_analytic_plan, _other_plans = self.env['account.analytic.plan']._get_all_plans()
other_analytic_plan2 = self.plan_b
analytic_account1, analytic_account2 = self.env['account.analytic.account'].create([
{
'name': 'Analytic Account 1',
'plan_id': project_analytic_plan.id,
},
{
'name': 'Analytic Account 2',
'plan_id': other_analytic_plan2.id,
},
])
sale_order = self.env['sale.order'].create({
'name': 'SO Test',
'partner_id': self.partner_a.id,
})
so_line1 = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet4.id,
'product_uom_qty': 10,
'order_id': sale_order.id,
'analytic_distribution': {f'{analytic_account1.id}, {analytic_account2.id}': 100},
})
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': self.project_global.id,
'unit_amount': 50,
'employee_id': self.employee_manager.id,
'so_line': so_line1.id,
})
self.assertEqual(timesheet.account_id, analytic_account1)
self.assertEqual(timesheet[other_analytic_plan2._column_name()], analytic_account2)
# Create another analytic account and a new SOL to assign it to the timesheet
other_analytic_plan3 = self.plan_c
analytic_account3 = self.env['account.analytic.account'].create({
'name': 'Analytic Account 3',
'plan_id': other_analytic_plan3.id,
})
so_line2 = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet4.id,
'product_uom_qty': 10,
'order_id': sale_order.id,
'analytic_distribution': {f'{analytic_account1.id}, {analytic_account3.id}': 100},
})
timesheet.so_line = so_line2
self.assertEqual(timesheet.account_id, analytic_account1)
self.assertFalse(timesheet[other_analytic_plan2._column_name()])
self.assertEqual(timesheet[other_analytic_plan3._column_name()], analytic_account3)
def test_timesheet_get_accounts_from_sol_fallback_on_project(self):
_project_analytic_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
other_analytic_plan2 = self.plan_b
analytic_account2 = self.env['account.analytic.account'].create({
'name': 'Analytic Account 2',
'plan_id': other_analytic_plan2.id,
})
sale_order = self.env['sale.order'].create({
'name': 'SO Test',
'partner_id': self.partner_a.id,
})
so_line = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet3.id,
'order_id': sale_order.id,
})
sale_order.action_confirm()
so_project = sale_order.project_id
so_line.analytic_distribution = {str(analytic_account2.id): 100}
timesheet = self.env['account.analytic.line'].create({
'name': 'Timesheet',
'project_id': so_project.id,
'unit_amount': 1,
'employee_id': self.employee_manager.id,
'so_line': so_line.id,
})
self.assertEqual(
timesheet._get_analytic_accounts(),
so_project.account_id | analytic_account2,
"The analytic accounts should be the account_id from the project and the accounts from the SOL's distribution",
)
def test_mandatory_plan_timesheet_applicability_from_sol(self):
plan_a = self.analytic_plan
plan_b = self.plan_b
analytic_account, _dummy = self.env['account.analytic.account'].create([{
'name': 'account',
'plan_id': plan.id,
} for plan in (plan_a, plan_b)])
self.env['account.analytic.applicability'].create({
'business_domain': 'timesheet',
'applicability': 'mandatory',
'analytic_plan_id': plan_b.id,
})
sale_order = self.env['sale.order'].create({
'name': 'SO Test',
'partner_id': self.partner_a.id,
})
so_line = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet4.id,
'product_uom_qty': 10,
'order_id': sale_order.id,
'analytic_distribution': {f'{analytic_account.id}': 100},
})
with self.assertRaises(ValidationError):
# The analytic plan 'other_analytic_plan' is mandatory on the sale order line linked to the timesheet
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': self.project_global.id,
'unit_amount': 50,
'employee_id': self.employee_manager.id,
'so_line': so_line.id,
})

View file

@ -9,21 +9,19 @@ from odoo.exceptions import UserError
class TestAccruedTimeSheetSaleOrders(TestCommonSaleTimesheet):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
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,
})

View file

@ -0,0 +1,76 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.addons.sale_project.tests.test_sale_project_dashboard import TestProjectDashboardCommon as Common
@tagged('-at_install', 'post_install')
class TestSaleTimesheetDashboard(Common):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.dashboard_product_delivery_timesheet = cls.env['product.product'].create({
'name': "Service delivered",
'standard_price': 11,
'list_price': 13,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'default_code': 'SERV-DELI1',
'service_type': 'timesheet',
'service_tracking': 'no',
'project_id': False,
'taxes_id': False,
})
def test_get_sale_item_data_various_sol_with_timesheet_installed(self):
"""This test ensures that when the timesheet module is installed, the sols are computed and put into the new profitability sections."""
hour_uom_id = self.env.ref('uom.product_uom_hour').id
unit_uom_id = self.env.ref('uom.product_uom_unit').id
sols = self.dashboardSaleOrderLine.create([{
'product_id': self.product_milestone.id,
'product_uom_qty': 1,
}, {
'product_id': self.product_prepaid.id,
'product_uom_qty': 1,
}, {
'product_id': self.material_product.id,
'product_uom_qty': 1,
}, {
'product_id': self.dashboard_product_delivery_timesheet.id,
'product_uom_qty': 1,
}, {
'product_id': self.dashboard_product_delivery_service.id,
'product_uom_qty': 1,
}])
sale_item_data = self.dashboard_project.get_sale_items_data(with_action=False, limit=5, section_id='materials')
expected_dict = sols._read_format(
['display_name', 'product_uom_qty', 'qty_delivered', 'qty_invoiced', 'product_uom_id', 'product_id']
)
self.assertEqual(
sale_item_data['sol_items'][0],
expected_dict[2]
)
sale_item_data = self.dashboard_project.get_sale_items_data(with_action=False, limit=5, section_id='billable_fixed')
self.assertEqual(
sale_item_data['sol_items'][0],
expected_dict[1]
)
sale_item_data = self.dashboard_project.get_sale_items_data(with_action=False, limit=5, section_id='billable_milestones')
self.assertEqual(
sale_item_data['sol_items'][0],
expected_dict[0]
)
sale_item_data = self.dashboard_project.get_sale_items_data(with_action=False, limit=5, section_id='billable_time')
self.assertEqual(
sale_item_data['sol_items'][0],
expected_dict[3]
)
sale_item_data = self.dashboard_project.get_sale_items_data(with_action=False, limit=5, section_id='billable_manual')
self.assertEqual(
sale_item_data['sol_items'][0],
expected_dict[4]
)

View file

@ -0,0 +1,29 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import ValidationError
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet
from odoo.tests import tagged
@tagged('-at_install', 'post_install')
class TestProductProduct(TestCommonSaleTimesheet):
def test_delete_master_timesheet_product(self):
"""
Test that the master timesheet product cannot be deleted, archived nor linked
to a company but regular products still can.
"""
time_product = self.env.ref('sale_timesheet.time_product')
with self.assertRaises(ValidationError):
time_product._unlink_except_master_data()
with self.assertRaises(ValidationError):
time_product.write({'active': False})
with self.assertRaises(ValidationError):
time_product.write({'company_id': self.env.company.id})
non_timesheet_product = self.product_delivery_timesheet5
non_timesheet_product.write({'company_id': self.env.company.id})
self.assertEqual(non_timesheet_product.company_id, self.env.company)
non_timesheet_product.write({'active': False})
self.assertFalse(non_timesheet_product.active)
non_timesheet_product._unlink_except_master_data()
self.assertFalse(self.env['product.product'].search([('id', '=', non_timesheet_product.id)]))

View file

@ -0,0 +1,29 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import ValidationError
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet
from odoo.tests import tagged
@tagged('-at_install', 'post_install')
class TestProductTemplate(TestCommonSaleTimesheet):
def test_delete_master_timesheet_product(self):
"""
Test that the master timesheet product cannot be deleted, archived nor linked
to a company but regular products still can.
"""
time_product = self.env.ref('sale_timesheet.time_product').product_tmpl_id
with self.assertRaises(ValidationError):
time_product.unlink()
with self.assertRaises(ValidationError):
time_product.write({'active': False})
with self.assertRaises(ValidationError):
time_product.write({'company_id': self.env.company.id})
non_timesheet_product = self.product_delivery_timesheet5.product_tmpl_id
non_timesheet_product.write({'company_id': self.env.company.id})
self.assertEqual(non_timesheet_product.company_id, self.env.company)
non_timesheet_product.write({'active': False})
self.assertFalse(non_timesheet_product.active)
non_timesheet_product.unlink()
self.assertFalse(self.env['product.template'].browse(non_timesheet_product.id).exists())

View file

@ -0,0 +1,50 @@
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet
from odoo.tests import tagged
from odoo import Command
@tagged('post_install', '-at_install')
class TestSaleTimesheetReport(TestCommonSaleTimesheet):
def test_timesheet_report_uom_difference(self):
'''Test the timesheet revenue are correctly computed in case of a
fixed price service
'''
uom_days = self.env.ref('uom.product_uom_day')
# Change product uom from hours to days
self.product_order_timesheet3.write({
'uom_id': uom_days,
})
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,
})
so_line = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet3.id,
'product_uom_qty': 3,
'order_id': sale_order.id,
'price_unit': 10.0,
'tax_ids': [Command.set(self.tax_sale_a.ids)],
})
sale_order.action_confirm()
task = self.env['project.task'].search([('sale_line_id', '=', so_line.id)])
# Register a timesheet entry of 24 hours
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,
'so_line': so_line.id,
'order_id': sale_order.id,
})
self.env.flush_all()
report = self.env['timesheets.analysis.report'].search([('so_line', '=', so_line.id)])
self.assertEqual(report.unit_amount, 24)
self.assertEqual(report.timesheet_revenues, 30)
self.assertEqual(report.billable_time, 24)

View file

@ -9,37 +9,29 @@ _logger = logging.getLogger(__name__)
@tagged('-at_install', 'post_install')
class TestUi(HttpCase):
class TestSaleTimesheetUi(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()
cls.env.ref('base.group_user').sudo().implied_ids |= cls.env.ref('project.group_project_milestone')
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)
self.env['product.pricelist'].with_context(active_test=False).search([]).unlink()
self.start_tour('/odoo', 'sale_timesheet_tour', login='admin', timeout=100)

View file

@ -129,6 +129,7 @@ class TestSoLineDeterminedInTimesheet(TestCommonSaleTimesheet):
timesheet = self.env['account.analytic.line'].create({
'name': 'Test Line',
'unit_amount': 1,
'auto_account_id': self.analytic_account_sale.id,
'employee_id': self.employee_manager.id,
'project_id': self.project_employee_rate.id,
'task_id': task.id,

View file

@ -0,0 +1,30 @@
from odoo.tests import tagged
from .common import TestCommonSaleTimesheet
@tagged('post_install', '-at_install')
class TestSaleTimesheetTaskAnalysis(TestCommonSaleTimesheet):
def test_remaining_hours_so(self):
sales_order = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
})
so_line_1 = self.env['sale.order.line'].create({
'product_id': self.product_order_timesheet3.id,
'product_uom_qty': 10,
'order_id': sales_order.id,
})
sales_order.action_confirm()
task_1 = self.env['project.task'].search([('sale_line_id', '=', so_line_1.id)])
task_2 = self.env['project.task'].create({
'name': "Task 2",
'project_id': task_1.project_id.id,
'partner_id': self.partner_a.id,
'sale_line_id': so_line_1.id,
})
self.assertEqual(task_2.remaining_hours_so, 10)
self.env.flush_all()
task_report = self.env['report.project.task.user'].search([('task_id', '=', task_2.id)])
self.assertEqual(task_report.remaining_hours_so, task_2.remaining_hours_so)

View file

@ -21,7 +21,7 @@ class TestUpsellWarning(TestCommonSaleTimesheet):
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.
5) Check if the SO has an 'mail.mail_activity_data_todo' activity.
"""
# 1) Configure the upsell warning in prepaid service product
self.product_order_timesheet1.write({
@ -48,7 +48,7 @@ class TestUpsellWarning(TestCommonSaleTimesheet):
'allow_timesheets': True,
'allow_billable': True,
'partner_id': self.partner_a.id,
'analytic_account_id': self.analytic_account_sale.id,
'account_id': self.analytic_account_sale.id,
})
task = self.env['project.task'].create({
'name': 'Task Test',
@ -71,7 +71,7 @@ class TestUpsellWarning(TestCommonSaleTimesheet):
# 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.')
self.assertEqual(len(so.activity_search(['mail.mail_activity_data_todo'])), 0, 'No upsell warning should appear in the SO.')
timesheet.write({
'unit_amount': 6,
})
@ -82,8 +82,8 @@ class TestUpsellWarning(TestCommonSaleTimesheet):
# 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.')
# 5) Check if the SO has an 'mail.mail_activity_data_todo' activity.
self.assertEqual(len(so.activity_search(['mail.mail_activity_data_todo'])), 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.
@ -98,7 +98,7 @@ class TestUpsellWarning(TestCommonSaleTimesheet):
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.
6) Check if the SO has an 'mail.mail_activity_data_todo' activity.
"""
# 1) Configure the upsell warning in prepaid service product with 100 (10000%)
@ -128,7 +128,7 @@ class TestUpsellWarning(TestCommonSaleTimesheet):
'allow_timesheets': True,
'allow_billable': True,
'partner_id': self.partner_a.id,
'analytic_account_id': self.analytic_account_sale.id,
'account_id': self.analytic_account_sale.id,
})
task = self.env['project.task'].create({
'name': 'Task Test',
@ -151,5 +151,100 @@ class TestUpsellWarning(TestCommonSaleTimesheet):
# 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.')
# 6) Check if the SO has an 'mail.mail_activity_data_todo' activity.
self.assertEqual(len(so.activity_search(['mail.mail_activity_data_todo'])), 0, 'No upsell warning should appear in the SO.')
def test_display_upsell_warning_multiple_times(self):
""" Test to display an upsell warning caused by an SO line that has already produced an upsell warning previously.
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) Update the ordered quantity of the SOL to match its delivered quantity
6) Mark the upsell activity as done,
7) Create Invoice of the SO,
8) Timesheet again in the task to satisfy the condition for the SOL to display an upsell warning,
9) Check if the SO has an 'mail.mail_activity_data_todo' activity.
"""
# 1) Configure the upsell warning in prepaid service product
self.product_order_timesheet1.write({
'service_upsell_threshold': 1.0,
})
# 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,
'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
self.env['account.analytic.line'].create({
'name': 'Timesheet1',
'unit_amount': 15,
'employee_id': self.employee_manager.id,
'project_id': project.id,
'task_id': task.id,
})
so.order_line._compute_qty_delivered()
# 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'])
self.assertEqual(len(so.activity_search(['mail.mail_activity_data_todo'])), 1, 'An upsell warning should appear in the SO.')
# 5) Update the ordered quantity of the SOL to match its delivered quantity
so.order_line.write({
'product_uom_qty': so.order_line.qty_delivered,
})
# 6) Mark the upsell activity as done
so.activity_search(['mail.mail_activity_data_todo'])._action_done()
# 7) 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'])
# No 'mail.mail_activity_data_todo' activity should appear as it was marked as done
self.assertEqual(len(so.activity_search(['mail.mail_activity_data_todo'])), 0, 'No upsell warning should appear in the SO.')
# 8) Timesheet again in the task to satisfy the condition for the SOL to display an upsell warning
self.env['account.analytic.line'].create({
'name': 'Timesheet2',
'unit_amount': 5,
'employee_id': self.employee_manager.id,
'project_id': project.id,
'task_id': task.id,
})
so.order_line._compute_qty_delivered()
# 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'])
# 9) Check if the SO has an 'mail.mail_activity_data_todo' activity
self.assertEqual(len(so.activity_search(['mail.mail_activity_data_todo'])), 1, 'A upsell warning should appear in the SO.')