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

@ -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)