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

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_sale_ui
from . import test_sale_order
from . import test_sale_order_template

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale.tests.common import SaleCommon
class SaleManagementCommon(SaleCommon):
@classmethod
@ -10,8 +10,15 @@ class SaleManagementCommon(SaleCommon):
super().setUpClass()
# Ensure user has access to sale order templates
cls.env.user.groups_id += cls.env.ref('sale_management.group_sale_order_template')
cls.env.user.group_ids += cls.env.ref('sale_management.group_sale_order_template')
cls.empty_order_template = cls.env['sale.order.template'].create({
'name': "Test Quotation Template",
})
@staticmethod
def _get_optional_product_lines(order):
"""Returns the order lines that are optional products. """
return order.order_line.filtered(
lambda line: not line.display_type and line._is_line_optional(),
)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from itertools import chain
@ -19,6 +18,7 @@ class TestSaleOrder(SaleManagementCommon):
# some variables to ease asserts in tests
cls.pub_product_price = 100.0
cls.pl_product_price = 80.0
cls._enable_discounts()
cls.tpl_discount = 10.0
cls.pl_discount = (cls.pub_product_price - cls.pl_product_price) * 100 / cls.pub_product_price
cls.merged_discount = 100.0 - (100.0 - cls.pl_discount) * (100.0 - cls.tpl_discount) / 100.0
@ -34,6 +34,7 @@ class TestSaleOrder(SaleManagementCommon):
{
'name': 'Product 1',
'lst_price': cls.pub_product_price,
'description_sale': "This is a product description"
}, {
'name': 'Optional product',
'lst_price': cls.pub_option_price,
@ -47,10 +48,15 @@ class TestSaleOrder(SaleManagementCommon):
Command.create({
'product_id': cls.product_1.id,
}),
],
'sale_order_template_option_ids': [
Command.create({
'name': 'Optional products',
'display_type': 'line_section',
'is_optional': True,
'sequence': 11, # to be sure optional products are last in the template
}),
Command.create({
'product_id': cls.optional_product.id,
'sequence': 12,
}),
],
})
@ -72,6 +78,22 @@ class TestSaleOrder(SaleManagementCommon):
'fixed_price': cls.pl_option_price,
}),
]
percentage_pricelist_rule_values = [
Command.create({
'name': 'Product 1 premium price',
'applied_on': '1_product',
'product_tmpl_id': cls.product_1.product_tmpl_id.id,
'compute_price': 'percentage',
'percent_price': cls.pl_discount,
}),
Command.create({
'name': 'Optional product premium price',
'applied_on': '1_product',
'product_tmpl_id': cls.optional_product.product_tmpl_id.id,
'compute_price': 'percentage',
'percent_price': cls.pl_option_discount,
}),
]
(
cls.discount_included_price_list,
@ -79,12 +101,10 @@ class TestSaleOrder(SaleManagementCommon):
) = cls.env['product.pricelist'].create([
{
'name': 'Discount included Pricelist',
'discount_policy': 'with_discount',
'item_ids': pricelist_rule_values,
}, {
'name': 'Discount excluded Pricelist',
'discount_policy': 'without_discount',
'item_ids': pricelist_rule_values,
'item_ids': percentage_pricelist_rule_values,
}
])
@ -105,8 +125,8 @@ class TestSaleOrder(SaleManagementCommon):
self.assertEqual(
len(self.sale_order.order_line),
1,
"The sale order shall contains the same number of products as"
3,
"The sale order shall contains the same number of lines as"
"the quotation template.")
self.assertEqual(
@ -121,40 +141,34 @@ class TestSaleOrder(SaleManagementCommon):
"Without any price list and discount, the public price of"
"the product shall be used.")
optional_lines = self._get_optional_product_lines(self.sale_order)
self.assertEqual(
len(self.sale_order.sale_order_option_ids),
len(optional_lines),
1,
"The sale order shall contains the same number of optional products as"
"the quotation template.")
self.assertEqual(
self.sale_order.sale_order_option_ids[0].product_id.id,
optional_lines[0].product_id.id,
self.optional_product.id,
"The sale order shall contains the same optional products as the"
"quotation template.")
self.assertEqual(
self.sale_order.sale_order_option_ids[0].price_unit,
optional_lines[0].price_unit,
self.pub_option_price,
"Without any price list and discount, the public price of"
"the optional product shall be used.")
# add the option to the order
self.sale_order.sale_order_option_ids[0].button_add_to_order()
self.assertEqual(
len(self.sale_order.order_line),
2,
"When an option is added, a new order line is created")
self.assertEqual(
self.sale_order.order_line[1].product_id.id,
self.sale_order.order_line[2].product_id.id,
self.optional_product.id,
"The sale order shall contains the same products as the"
"quotation template.")
self.assertEqual(
self.sale_order.order_line[1].price_unit,
self.sale_order.order_line[2].price_unit,
self.pub_option_price,
"Without any price list and discount, the public price of"
"the optional product shall be used.")
@ -179,17 +193,16 @@ class TestSaleOrder(SaleManagementCommon):
"If a pricelist is set, the product price shall be computed"
"according to it.")
optional_lines = self._get_optional_product_lines(self.sale_order)
self.assertEqual(
self.sale_order.sale_order_option_ids[0].price_unit,
optional_lines[0].price_unit,
self.pl_option_price,
"If a pricelist is set, the optional product price shall"
"be computed according to it.")
# add the option to the order
self.sale_order.sale_order_option_ids[0].button_add_to_order()
self.assertEqual(
self.sale_order.order_line[1].price_unit,
self.sale_order.order_line[2].price_unit,
self.pl_option_price,
"If a pricelist is set, the optional product price shall"
"be computed according to it.")
@ -225,36 +238,35 @@ class TestSaleOrder(SaleManagementCommon):
"shall be computed according to the price unit and the subtotal."
"price")
optional_lines = self._get_optional_product_lines(self.sale_order)
self.assertEqual(
self.sale_order.sale_order_option_ids[0].price_unit,
optional_lines[0].price_unit,
self.pub_option_price,
"If a pricelist is set without discount included, the unit "
"price shall be the public optional product price.")
self.assertEqual(
self.sale_order.sale_order_option_ids[0].discount,
optional_lines[0].discount,
self.pl_option_discount,
"If a pricelist is set without discount included, the discount "
"shall be computed according to the optional price unit and"
"the subtotal price.")
# add the option to the order
self.sale_order.sale_order_option_ids[0].button_add_to_order()
self.assertEqual(
self.sale_order.order_line[1].price_unit,
self.sale_order.order_line[2].price_unit,
self.pub_option_price,
"If a pricelist is set without discount included, the unit "
"price shall be the public optional product price.")
self.assertEqual(
self.sale_order.order_line[1].price_subtotal,
self.sale_order.order_line[2].price_subtotal,
self.pl_option_price,
"If a pricelist is set without discount included, the subtotal "
"price shall be the price computed according to the price list.")
self.assertEqual(
self.sale_order.order_line[1].discount,
self.sale_order.order_line[2].discount,
self.pl_option_discount,
"If a pricelist is set without discount included, the discount "
"shall be computed according to the price unit and the subtotal."
@ -270,13 +282,15 @@ class TestSaleOrder(SaleManagementCommon):
})
self.sale_order._onchange_sale_order_template_id()
optional_lines = self._get_optional_product_lines(self.sale_order)
self.assertEqual(
self.sale_order.sale_order_option_ids[0].price_unit,
optional_lines[0].price_unit,
self.pub_option_price,
"If no pricelist is set, the unit price shall be the option's product price.")
self.assertEqual(
self.sale_order.sale_order_option_ids[0].discount, 0,
optional_lines[0].discount, 0,
"If no pricelist is set, the discount should be 0.")
self.sale_order.write({
@ -285,13 +299,13 @@ class TestSaleOrder(SaleManagementCommon):
self.sale_order._recompute_prices()
self.assertEqual(
self.sale_order.sale_order_option_ids[0].price_unit,
optional_lines[0].price_unit,
self.pl_option_price,
"If a pricelist is set with discount included,"
" the unit price shall be the option's product discounted price.")
self.assertEqual(
self.sale_order.sale_order_option_ids[0].discount, 0,
optional_lines[0].discount, 0,
"If a pricelist is set with discount included,"
" the discount should be 0.")
@ -301,25 +315,17 @@ class TestSaleOrder(SaleManagementCommon):
self.sale_order._recompute_prices()
self.assertEqual(
self.sale_order.sale_order_option_ids[0].price_unit,
optional_lines[0].price_unit,
self.pub_option_price,
"If a pricelist is set without discount included,"
" the unit price shall be the option's product sale price.")
self.assertEqual(
self.sale_order.sale_order_option_ids[0].discount,
optional_lines[0].discount,
self.pl_option_discount,
"If a pricelist is set without discount included,"
" the discount should be correctly computed.")
def test_option_creation(self):
"""Make sure the product uom is automatically added to the option when the product is specified"""
order_form = Form(self.sale_order)
with order_form.sale_order_option_ids.new() as option:
option.product_id = self.product_1
order = order_form.save()
self.assertTrue(bool(order.sale_order_option_ids.uom_id))
def test_option_price_unit_is_not_recomputed(self):
"""
Verifies that user defined price unit for optional products remains the same after
@ -328,16 +334,24 @@ class TestSaleOrder(SaleManagementCommon):
sale_order_with_option = self.env['sale.order'].create({
'partner_id': self.partner.id,
'sale_order_option_ids': [Command.create({
'product_id': self.optional_product.id,
'price_unit': 10,
})],
'order_line': [
Command.create({
'display_type': 'line_section',
'name': "Optional products",
'is_optional': True,
}),
Command.create({
'product_id': self.optional_product.id,
}),
],
})
sale_order_with_option.sale_order_option_ids.add_option_to_order()
optional_product_line = self._get_optional_product_lines(sale_order_with_option)
optional_product_line.price_unit = 100
# after changing the quantity of the product, the price unit should not be recomputed
sale_order_with_option.order_line.product_uom_qty = 10
self.assertEqual(sale_order_with_option.sale_order_option_ids.price_unit, 10)
optional_product_line.product_uom_qty = 10
self.assertEqual(optional_product_line.price_unit, 100)
def test_reload_template_translations(self):
"""
@ -354,17 +368,21 @@ class TestSaleOrder(SaleManagementCommon):
'display_type': 'line_note',
}),
]
# Remove product description to ease comparing before/after translations
self.product_1.description_sale = None
# Commence activation of Dutch vernacular
self.env['res.lang']._activate_lang('nl_NL')
partner_NL = self.partner.copy({'lang': 'nl_NL', 'name': "Pieter-Jan Hollandman"})
names_EN = ["Product 1", "Section 1", "Note 1", "Optional product"]
names_NL = ["Artikel 1", "Sectie 1", "Nota 1", "Optioneel artikel"]
names_EN = ["Product 1", "Section 1", "Note 1", "Optional products", "Optional product"]
names_NL = ["Artikel 1", "Sectie 1", "Nota 1", "Optionele producten", "Optioneel product"]
trans_dict = dict(zip(names_EN, names_NL))
for record in chain(
self.quotation_template_no_discount.sale_order_template_line_ids,
self.quotation_template_no_discount.sale_order_template_option_ids,
self.quotation_template_no_discount.sale_order_template_line_ids.product_id,
):
if not record.name:
continue
record.with_context(lang='nl_NL').name = trans_dict[record.name]
# Create sale order form (and a way to retrieve line names)
@ -373,7 +391,8 @@ class TestSaleOrder(SaleManagementCommon):
form.order_line.edit(0).name,
form.order_line.edit(1).name,
form.order_line.edit(2).name,
form.sale_order_option_ids.edit(0).name,
form.order_line.edit(3).name,
form.order_line.edit(4).name,
]
order_form = Form(self.sale_order.browse())
@ -412,15 +431,7 @@ class TestSaleOrder(SaleManagementCommon):
"Lines should change after manual template reload",
)
# Add a line & return to Dutch
with order_form.sale_order_option_ids.new() as optional_product:
optional_product.product_id = self.product
order_form.partner_id = partner_NL
self.assertSequenceEqual(
get_form_field_names(order_form),
names_EN,
"Lines shouldn't change after a new one was added",
)
# Reload template, save, and change partner again
order_form.sale_order_template_id = self.quotation_template_no_discount
@ -431,3 +442,83 @@ class TestSaleOrder(SaleManagementCommon):
names_NL,
"Lines shouldn't change once saved",
)
def test_product_description_no_template_description(self):
"""
Test case for when the product has a description, but the quotation template line does not.
The final sale order line should use the product's description.
"""
quotation_template_no_description = self.empty_order_template
quotation_template_no_description.sale_order_template_line_ids = [
Command.create({
'product_id': self.product_1.id,
'name': False,
}),
]
sale_order = self.empty_order
sale_order.sale_order_template_id = quotation_template_no_description
sale_order._onchange_sale_order_template_id()
self.assertEqual(
sale_order.order_line[0].name,
f"{self.product_1.name}\n{self.product_1.description_sale}",
"Sale order line should use product's description when no quotation template \
description is set."
)
def test_product_description_with_template_description(self):
"""
Test case for when both the product and the quotation template line have descriptions.
The final sale order line should use the template's description.
"""
quotation_template_with_description = self.empty_order_template
quotation_template_with_description.sale_order_template_line_ids = [
Command.create({
'product_id': self.product_1.id,
'name': "This is a template description",
}),
]
sale_order = self.empty_order
sale_order.sale_order_template_id = quotation_template_with_description
sale_order._onchange_sale_order_template_id()
self.assertEqual(
sale_order.order_line[0].name,
quotation_template_with_description.sale_order_template_line_ids[0].name,
"The sale order line should use the quotation template's description when both \
product and the quotation template descriptions are set."
)
def test_warning_quotation(self):
"""
ensure "warning for the change of your quotation's company" isn't triggered
during the creation of a quotation when a quotation template is set as default
"""
quotation_template = self.empty_order_template
quotation_template.sale_order_template_line_ids = [
Command.create({'product_id': self.product.id})
]
self.env['ir.default'].set('sale.order', 'sale_order_template_id', quotation_template.id)
try:
with self.assertLogs('odoo.tests.form.onchange') as log_catcher:
Form(self.env['sale.order'])
except AssertionError:
pass
self.assertEqual(len(log_catcher.output), 0, "Form creation shouldn't trigger a warning")
def test_show_update_pricelist_false_on_sale_order_open(self):
"""Ensure the update pricelist button is disabled when opening a sale order
with a default quotation template applied.
"""
quotation_template = self.env['sale.order.template'].create({
'name': 'Test Quotation Template',
'sale_order_template_line_ids': [
Command.create({
'product_id': self.product.id,
}),
],
})
self.env['ir.default'].set('sale.order', 'sale_order_template_id', quotation_template.id)
with Form(self.env['sale.order']) as sale_order_form:
self.assertTrue(sale_order_form.sale_order_template_id)
self.assertTrue(sale_order_form.order_line)
self.assertFalse(sale_order_form.show_update_pricelist)
sale_order_form.partner_id = self.partner

View file

@ -0,0 +1,113 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import UserError, ValidationError
from odoo.fields import Command
from odoo.tests import tagged
from odoo.addons.sale_management.tests.common import SaleManagementCommon
@tagged('-at_install', 'post_install')
class TestSaleOrderTemplate(SaleManagementCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.branch_company, cls.other_company = cls.env['res.company'].create([
{
'name': "Branch company",
'parent_id': cls.company.id,
},
{'name': "Other Company"},
])
(
cls.parent_company_product,
cls.branch_company_product,
cls.other_company_product,
) = cls.env['product.product'].create([
{
'name': 'Parent company product',
'company_id': cls.company.id,
},
{
'name': 'Branch company product',
'company_id': cls.branch_company.id,
},
{
'name': 'Other company product',
'company_id': cls.other_company.id,
},
])
def test_no_restricted_product_on_shared_template(self):
self.empty_order_template.company_id = False
with self.assertRaises(UserError):
self.empty_order_template.sale_order_template_line_ids = [
Command.create({
'product_id': self.parent_company_product.id,
}),
]
def test_template_cannot_use_unrelated_company_products(self):
# Access to products of other companies
with self.assertRaises(UserError):
self.empty_order_template.sale_order_template_line_ids = [
Command.create({
'product_id': self.other_company_product.id,
}),
]
def test_parent_template_cannot_use_branch_company_products(self):
with self.assertRaises(UserError):
self.empty_order_template.sale_order_template_line_ids = [
Command.create({
'product_id': self.branch_company_product.id,
}),
]
def test_branch_template_can_use_parent_company_products(self):
self.assertFalse(self.product.company_id)
self.empty_order_template.company_id = self.branch_company.id
self.empty_order_template.write({
'sale_order_template_line_ids': [
Command.create({
'product_id': self.branch_company_product.id,
}),
Command.create({
'product_id': self.parent_company_product.id,
}),
Command.create({ # Shared product
'product_id': self.product.id,
}),
],
})
def test_company_changes_on_template(self):
"""Test `_check_company_id` constraint.
Since most multi-company issues are already catched by the automated `check_company` logic
(see other tests), we have to trigger issues the other way (through the template field) to
test the constraint.
"""
self.empty_order_template.write({
'company_id': self.company.id,
'sale_order_template_line_ids': [
Command.create({
'product_id': self.parent_company_product.id,
})
],
})
# Branch company is allowed to use parent company products
self.empty_order_template.company_id = self.branch_company.id
# Cannot share template if contains restricted products
with self.assertRaises(ValidationError):
self.empty_order_template.company_id = False
# Template cannot hold products from other companies
with self.assertRaises(ValidationError):
self.empty_order_template.company_id = self.other_company.id

View file

@ -1,19 +1,68 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests.common import HttpCase, tagged
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests.common import tagged, HttpCase
from odoo.addons.sale.tests.common import TestSaleCommon
@tagged('post_install', '-at_install')
class TestUi(AccountTestInvoicingCommon, HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.agrolait = cls.env['res.partner'].create({'name': 'Agrolait', 'email': 'agro@lait.be'})
def test_01_sale_tour(self):
self.start_tour("/web", 'sale_tour', login="admin", step_delay=100)
self.env.ref('base.user_admin').write({
'email': 'mitchell.admin@example.com',
})
self.start_tour("/odoo", 'sale_tour', login="admin")
def test_02_sale_tour_company_onboarding_done(self):
self.env.company.set_onboarding_step_done('base_onboarding_company_state')
self.start_tour("/web", 'sale_tour', login="admin", step_delay=100)
def test_04_portal_sale_signature_without_name_tour(self):
"""The goal of this test is to make sure the portal user can sign SO even witout a name."""
self.agrolait.name = ""
def test_03_sale_quote_tour(self):
self.env['res.partner'].create({'name': 'Agrolait', 'email': 'agro@lait.be'})
self.start_tour("/web", 'sale_quote_tour', login="admin", step_delay=100)
sales_order = self.env['sale.order'].sudo().create({
'name': 'test SO',
'partner_id': self.agrolait.id,
'state': 'sent',
'require_payment': False,
'order_line': [
Command.create({
'product_id': self.product.id,
})
]
})
action = sales_order.action_preview_sale_order()
self.start_tour(action['url'], 'sale_signature_without_name', login="admin")
@tagged('-at_install', 'post_install')
class TestSaleFlowTourPostInstall(TestSaleCommon, HttpCase):
def test_basic_sale_flow_with_minimal_access_rights(self):
"""
Test that a sale user with minimal access rights (own document only) can open both the
list and form view, create and process a sale order and open the associated invoice.
"""
sale_user = self.env['res.users'].create({
'name': 'Super Sale Woman',
'login': 'SuperSaleWoman',
'group_ids': [Command.set([self.ref('sales_team.group_sale_salesman')])],
})
# create and confirm a sale order to populate the list view
sale_order = self.env['sale.order'].with_user(sale_user.id).create({
'partner_id': self.partner_a.id,
'order_line': [Command.create({
'name': self.product.name,
'product_id': self.product.id,
'product_uom_qty': 1,
})],
})
sale_order.action_confirm()
self.start_tour('/odoo', 'test_basic_sale_flow_with_minimal_access_rights', login='SuperSaleWoman')