odoo18/addons/sale_project/tests/test_so_line_milestones.py

319 lines
14 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.tests import Form
from odoo.addons.sale.tests.common import TestSaleCommon
from odoo.exceptions import ValidationError
from odoo.tests.common import tagged
from psycopg2.errors import NotNullViolation
@tagged('post_install', '-at_install')
class TestSoLineMilestones(TestSaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env['res.config.settings'].create({'group_project_milestone': True}).execute()
uom_hour = cls.env.ref('uom.product_uom_hour')
cls.product_delivery_milestones1 = cls.env['product.product'].create({
'name': "Milestones 1, create project only",
'standard_price': 15,
'list_price': 30,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'MILE-DELI4',
'service_type': 'milestones',
'service_tracking': 'project_only',
})
cls.product_delivery_milestones2 = cls.env['product.product'].create({
'name': "Milestones 2, create project only",
'standard_price':20,
'list_price': 35,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'MILE-DELI4',
'service_type': 'milestones',
'service_tracking': 'project_only',
})
cls.product_delivery_milestones3 = cls.env['product.product'].create({
'name': "Milestones 3, create project & task",
'standard_price': 20,
'list_price': 35,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'MILE-DELI4',
'service_type': 'milestones',
'service_tracking': 'task_in_project',
})
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,
})
cls.sol1 = cls.env['sale.order.line'].create({
'product_id': cls.product_delivery_milestones1.id,
'product_uom_qty': 20,
'order_id': cls.sale_order.id,
})
cls.sol2 = cls.env['sale.order.line'].create({
'product_id': cls.product_delivery_milestones2.id,
'product_uom_qty': 30,
'order_id': cls.sale_order.id,
})
cls.sale_order.action_confirm()
cls.project = cls.sol1.project_id
cls.milestone1 = cls.env['project.milestone'].create({
'name': 'Milestone 1',
'project_id': cls.project.id,
'is_reached': False,
'sale_line_id': cls.sol1.id,
'quantity_percentage': 0.5,
})
def test_reached_milestones_delivered_quantity(self):
self.milestone2 = self.env['project.milestone'].create({
'name': 'Milestone 2',
'project_id': self.project.id,
'is_reached': False,
'sale_line_id': self.sol2.id,
'quantity_percentage': 0.2,
})
self.milestone3 = self.env['project.milestone'].create({
'name': 'Milestone 3',
'project_id': self.project.id,
'is_reached': False,
'sale_line_id': self.sol2.id,
'quantity_percentage': 0.4,
})
self.assertEqual(self.sol1.qty_delivered, 0.0, "Delivered quantity should start at 0")
self.assertEqual(self.sol2.qty_delivered, 0.0, "Delivered quantity should start at 0")
self.milestone1.is_reached = True
self.assertEqual(self.sol1.qty_delivered, 10.0, "Delivered quantity should update after a milestone is reached")
self.milestone2.is_reached = True
self.assertEqual(self.sol2.qty_delivered, 6.0, "Delivered quantity should update after a milestone is reached")
self.milestone3.is_reached = True
self.assertEqual(self.sol2.qty_delivered, 18.0, "Delivered quantity should update after a milestone is reached")
def test_update_reached_milestone_quantity(self):
self.milestone1.is_reached = True
self.assertEqual(self.sol1.qty_delivered, 10.0, "Delivered quantity should start at 10")
self.milestone1.quantity_percentage = 0.75
self.assertEqual(self.sol1.qty_delivered, 15.0, "Delivered quantity should update after a milestone's quantity is updated")
def test_remove_reached_milestone(self):
self.milestone1.is_reached = True
self.assertEqual(self.sol1.qty_delivered, 10.0, "Delivered quantity should start at 10")
self.milestone1.unlink()
self.assertEqual(self.sol1.qty_delivered, 0.0, "Delivered quantity should update when a milestone is removed")
def test_compute_sale_line_in_task(self):
task = self.env['project.task'].create({
'name': 'Test Task',
'project_id': self.project.id,
})
self.assertEqual(task.sale_line_id, self.sol1, 'The task should have the one of the project linked')
self.project.sale_line_id = False
task.sale_line_id = False
self.assertFalse(task.sale_line_id)
task.write({'milestone_id': self.milestone1.id})
self.assertEqual(task.sale_line_id, self.milestone1.sale_line_id, 'The task should have the SOL from the milestone.')
self.project.sale_line_id = self.sol2
self.assertEqual(task.sale_line_id, self.sol1, 'The task should keep the SOL linked to the milestone.')
def test_default_values_milestone(self):
""" This test checks that newly created milestones have the correct default values:
1) the first SOL of the SO linked to the project should be used as the default one.
2) the quantity percentage should be 100% (1.0 in backend).
"""
project = self.env['project.project'].create({
'name': 'Test project',
'sale_line_id': self.sol2.id, # sol1 was created first so we use sol2 to demonstrate that sol1 is used
})
milestone = self.env['project.milestone'].with_context({'default_project_id': project.id}).create({
'name': 'Test milestone',
'project_id': project.id,
'is_reached': False,
})
# since SOL1 was created before SOL2, it should be selected
self.assertEqual(milestone.sale_line_id, self.sol1, "The milestone's sale order line should be the first one in the project's SO") #1
self.assertEqual(milestone.quantity_percentage, 1.0, "The milestone's quantity percentage should be 1.0") #2
def test_compute_qty_milestone(self):
""" This test will check that the compute methods for the milestone quantity fields work properly. """
ratio = self.milestone1.quantity_percentage / self.milestone1.product_uom_qty
self.milestone1.quantity_percentage = 1.0
self.assertEqual(self.milestone1.quantity_percentage / self.milestone1.product_uom_qty, ratio, "The ratio should be the same as before")
self.milestone1.product_uom_qty = 25
self.assertEqual(self.milestone1.quantity_percentage / self.milestone1.product_uom_qty, ratio, "The ratio should be the same as before")
def test_create_milestone_on_project_set_on_sales_order(self):
"""
Regression Test:
If we confirm an SO with a service with a delivery based on milestones,
that creates both a project & task, and we set a project on the SO,
the project for the milestone should be the one set on the SO,
and no ValidationError or NotNullViolation should be raised.
"""
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,
})
self.env['sale.order.line'].create({
'product_id': self.product_delivery_milestones3.id,
'product_uom_qty': 20,
'order_id': sale_order.id,
})
try:
sale_order.action_confirm()
except (ValidationError, NotNullViolation):
self.fail("The sale order should be confirmed, "
"and no ValidationError or NotNullViolation should be raised, "
"for a missing project on the milestone.")
def test_so_with_milestone_products(self):
"""
If a SO contains products invoiced based on milestones, a milestone should be created for each of them
in their project.
"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
})
products = self.product_delivery_milestones1 | self.product_delivery_milestones2 | self.product_delivery_milestones3
products.service_tracking = 'task_in_project'
self.env['sale.order.line'].create([{
'product_id': product.id,
'product_uom_qty': 20,
'order_id': sale_order.id,
} for product in products])
sale_order.action_confirm()
project = sale_order.project_ids
self.assertEqual(len(project.milestone_ids), 3, "The project should have a milestone for each product.")
self.assertCountEqual({m.name for m in project.milestone_ids}, {f"[{products[0].default_code}] {p.name}" for p in products}, "The milestones should be named after the products.")
def test_project_template_with_milestones(self):
"""
If a milestone product has a project template with configured milestones, use those instead of creating
a new milestone and set a quantity equal to the quantity of the SOL divided by the number of milestones.
"""
project_template = self.env['project.project'].create({
'name': 'Project Template',
})
self.env['project.milestone'].create([{
'project_id': project_template.id,
'name': str(i),
} for i in range(4)])
self.product_delivery_milestones1.project_template_id = project_template.id
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
})
self.env['sale.order.line'].create({
'product_id': self.product_delivery_milestones1.id,
'product_uom_qty': 20,
'order_id': sale_order.id,
})
sale_order.action_confirm()
project = sale_order.project_ids
self.assertEqual(len(project.milestone_ids), 4, "The generated project should have 4 milestones.")
self.assertEqual({m.quantity_percentage for m in project.milestone_ids}, {0.25}, "All milestones of the generated project should have a quantity percentage of 25%.")
def test_project_template_with_milestones_multiple_products(self):
"""
If multiple products use the same project template, which has configured milestones, use the first product
on those milestones, but generate the other default milestones as normal
"""
project_template = self.env['project.project'].create({
'name': 'Project Template',
})
self.env['project.milestone'].create([{
'project_id': project_template.id,
'name': str(i),
} for i in range(4)])
products = self.product_delivery_milestones1 | self.product_delivery_milestones2
products.write({
'project_template_id': project_template.id,
'service_tracking': 'task_in_project',
})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
})
self.env['sale.order.line'].create([{
'product_id': product.id,
'product_uom_qty': 20,
'order_id': sale_order.id,
} for product in products])
sale_order.action_confirm()
project = sale_order.project_ids
self.assertEqual(len(project.milestone_ids), 5, "The project should have 5 milestones")
def test_subtask_milestone_sol(self):
""" A task should keep its sale line according to its milestone is changed. """
# Create a sale order with two milestone lines
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.product_delivery_milestones3.id,
'product_uom_qty': 1,
'name': name,
}) for name in ["m1", "m2"]
]
})
sale_order.action_confirm()
# Case 1: parent task is present set SOL according parent's SOL
parent_task = self.env['project.task'].create({
'name': 'Test Task',
'partner_id': self.partner.id,
'project_id': sale_order.project_id.id,
'sale_line_id': self.sol1.id
})
tasks = sale_order.project_id.task_ids
tasks[0].parent_id = parent_task.id
with Form(tasks[0]) as task_form:
task_form.sale_line_id = self.env['sale.order.line']
task_form.milestone_id = tasks[1].milestone_id
self.assertEqual(tasks[0].sale_line_id,
parent_task.sale_line_id,
"Task should have the correct sale line based on parent task.")
# Case 2: parent task not present set SOL according Milestone's SOL
tasks[0].parent_id = False
with Form(tasks[0]) as task_form:
task_form.sale_line_id = self.env['sale.order.line']
task_form.milestone_id = tasks[0].milestone_id
self.assertEqual(tasks[0].sale_line_id,
tasks[0].milestone_id.sale_line_id,
"Task should have the correct sale line based on milestone.")
# Case 3: parent task and milestone not present set SOL according project's SOL
with Form(tasks[0]) as task_form:
task_form.sale_line_id = self.env['sale.order.line']
task_form.milestone_id = self.env['project.milestone']
self.assertEqual(tasks[0].sale_line_id,
tasks[0].project_id.sale_line_id,
"Task should have the correct sale line based on project.")