odoo18/addons/sale/tests/test_sale_order.py

1149 lines
48 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from unittest.mock import patch
from freezegun import freeze_time
from odoo import fields
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.fields import Command
from odoo.tests import Form, HttpCase, tagged
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestSaleOrder(SaleCommon):
# Those tests do not rely on accounting common on purpose
# If you need the accounting setup, use other classes (TestSaleToInvoice probably)
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner1, cls.partner2 = cls.env['res.partner'].create([
{'name': 'Partner 1'},
{'name': 'Partner 2'},
])
def test_computes_auto_fill(self):
free_product, dummy_product = self.env['product.product'].create([{
'name': 'Free product',
'list_price': 0.0,
}, {
'name': 'Dummy product',
'list_price': 0.0,
}])
# Test pre-computes of lines with order
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'display_type': 'line_section',
'name': 'Dummy section',
}),
Command.create({
'display_type': 'line_section',
'name': 'Dummy section',
}),
Command.create({
'product_id': free_product.id,
}),
Command.create({
'product_id': dummy_product.id,
})
]
})
# Test pre-computes of lines creation alone
# Ensures the creation works fine even if the computes
# are triggered after the defaults
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
})
self.env['sale.order.line'].create([
{
'display_type': 'line_section',
'name': 'Dummy section',
'order_id': order.id,
}, {
'display_type': 'line_section',
'name': 'Dummy section',
'order_id': order.id,
}, {
'product_id': free_product.id,
'order_id': order.id,
}, {
'product_id': dummy_product.id,
'order_id': order.id,
}
])
def test_sale_order_standard_flow(self):
self.assertEqual(self.sale_order.amount_total, 725.0, 'Sale: total amount is wrong')
self.sale_order.order_line._compute_product_updatable()
self.assertTrue(self.sale_order.order_line[0].product_updatable)
# send quotation
email_act = self.sale_order.action_quotation_send()
email_ctx = email_act.get('context', {})
self.sale_order.with_context(**email_ctx).message_post_with_source(
self.env['mail.template'].browse(email_ctx.get('default_template_id')),
subtype_xmlid='mail.mt_comment',
)
self.assertTrue(self.sale_order.state == 'sent', 'Sale: state after sending is wrong')
self.sale_order.order_line._compute_product_updatable()
self.assertTrue(self.sale_order.order_line[0].product_updatable)
# confirm quotation
self.sale_order.action_confirm()
self.assertTrue(self.sale_order.state == 'sale')
self.assertTrue(self.sale_order.invoice_status == 'to invoice')
def test_sale_order_send_to_self(self):
# when sender(logged in user) is also present in recipients of the mail composer,
# user should receive mail.
sale_order = self.env['sale.order'].with_user(self.sale_user).create({
'partner_id': self.sale_user.partner_id.id,
})
email_ctx = sale_order.action_quotation_send().get('context', {})
# We need to prevent auto mail deletion, and so we copy the template and send the mail with
# added configuration in copied template. It will allow us to check whether mail is being
# sent to to author or not (in case author is present in 'Recipients' of composer).
mail_template = self.env['mail.template'].browse(email_ctx.get('default_template_id')).copy({'auto_delete': False})
# send the mail with same user as customer
sale_order.with_context(**email_ctx).with_user(self.sale_user).message_post_with_source(
mail_template,
subtype_xmlid='mail.mt_comment',
)
self.assertTrue(sale_order.state == 'sent', 'Sale : state should be changed to sent')
mail_message = sale_order.message_ids[0]
self.assertEqual(mail_message.author_id, sale_order.partner_id, 'Sale: author should be same as customer')
self.assertEqual(mail_message.author_id, mail_message.partner_ids, 'Sale: author should be in composer recipients thanks to "partner_to" field set on template')
self.assertEqual(mail_message.partner_ids, mail_message.sudo().mail_ids.recipient_ids, 'Sale: author should receive mail due to presence in composer recipients')
def test_sale_sequence(self):
self.env['ir.sequence'].search([
('code', '=', 'sale.order'),
]).write({
'use_date_range': True, 'prefix': 'SO/%(range_year)s/',
})
sale_order = self.sale_order.copy({'date_order': '2019-01-01'})
self.assertTrue(sale_order.name.startswith('SO/2019/'))
sale_order = self.sale_order.copy({'date_order': '2020-01-01'})
self.assertTrue(sale_order.name.startswith('SO/2020/'))
# In EU/BXL tz, this is actually already 01/01/2020
sale_order = self.sale_order.with_context(tz='Europe/Brussels').copy({'date_order': '2019-12-31 23:30:00'})
self.assertTrue(sale_order.name.startswith('SO/2020/'))
def test_unlink_cancel(self):
""" Test deleting and cancelling sales orders depending on their state and on the user's rights """
# SO in state 'draft' can be deleted
so_copy = self.sale_order.copy()
with self.assertRaises(AccessError):
so_copy.with_user(self.sale_user).unlink()
self.assertTrue(so_copy.unlink(), 'Sale: deleting a quotation should be possible')
# SO in state 'cancel' can be deleted
so_copy = self.sale_order.copy()
so_copy.action_confirm()
self.assertTrue(so_copy.state == 'sale', 'Sale: SO should be in state "sale"')
so_copy._action_cancel()
self.assertTrue(so_copy.state == 'cancel', 'Sale: SO should be in state "cancel"')
with self.assertRaises(AccessError):
so_copy.with_user(self.sale_user).unlink()
self.assertTrue(so_copy.unlink(), 'Sale: deleting a cancelled SO should be possible')
# SO in state 'sale' cannot be deleted
self.sale_order.action_confirm()
self.assertTrue(self.sale_order.state == 'sale', 'Sale: SO should be in state "sale"')
with self.assertRaises(UserError):
self.sale_order.unlink()
self.sale_order.action_lock()
self.assertTrue(self.sale_order.state == 'sale')
self.assertTrue(self.sale_order.locked)
with self.assertRaises(UserError):
self.sale_order.unlink()
def test_compute_packaging_00(self):
"""Create a SO and use packaging. Check we suggested suitable packaging
according to the product_qty. Also check product_qty or product_packaging
are correctly calculated when one of them changed.
"""
# Required for `product_packaging_qty` to be visible in the view
self.env.user.groups_id += self.env.ref('product.group_stock_packaging')
packaging_single, packaging_dozen = self.env['product.packaging'].create([{
'name': "I'm a packaging",
'product_id': self.product.id,
'qty': 1.0,
}, {
'name': "I'm also a packaging",
'product_id': self.product.id,
'qty': 12.0,
}])
so = self.empty_order
so_form = Form(so)
with so_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 1.0
so_form.save()
self.assertEqual(so.order_line.product_packaging_id, packaging_single)
self.assertEqual(so.order_line.product_packaging_qty, 1.0)
with so_form.order_line.edit(0) as line:
line.product_packaging_qty = 2.0
so_form.save()
self.assertEqual(so.order_line.product_uom_qty, 2.0)
with so_form.order_line.edit(0) as line:
line.product_uom_qty = 24.0
so_form.save()
self.assertEqual(so.order_line.product_packaging_id, packaging_dozen)
self.assertEqual(so.order_line.product_packaging_qty, 2.0)
with so_form.order_line.edit(0) as line:
line.product_packaging_qty = 1.0
so_form.save()
self.assertEqual(so.order_line.product_uom_qty, 12)
packaging_pack_of_10 = self.env['product.packaging'].create({
'name': "PackOf10",
'product_id': self.product.id,
'qty': 10.0,
})
packaging_pack_of_20 = self.env['product.packaging'].create({
'name': "PackOf20",
'product_id': self.product.id,
'qty': 20.0,
})
so2 = self.env['sale.order'].create({
'partner_id': self.partner.id,
})
so2_form = Form(so2)
with so2_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 10
so2_form.save()
self.assertEqual(so2.order_line.product_packaging_id.id, packaging_pack_of_10.id)
self.assertEqual(so2.order_line.product_packaging_qty, 1.0)
with so2_form.order_line.edit(0) as line:
line.product_packaging_qty = 2
so2_form.save()
self.assertEqual(so2.order_line.product_uom_qty, 20)
# we should have 2 pack of 10, as we've set the package_qty manually,
# we shouldn't recompute the packaging_id, since the package_qty is protected,
# therefor cannot be recomputed during the same transaction, which could lead
# to an incorrect line like (qty=20,pack_qty=2,pack_id=PackOf20)
self.assertEqual(so2.order_line.product_packaging_qty, 2)
self.assertEqual(so2.order_line.product_packaging_id.id, packaging_pack_of_10.id)
with so2_form.order_line.edit(0) as line:
line.product_packaging_id = packaging_pack_of_20
so2_form.save()
self.assertEqual(so2.order_line.product_uom_qty, 20)
# we should have 1 pack of 20, as we've set the package type manually
self.assertEqual(so2.order_line.product_packaging_qty, 1)
self.assertEqual(so2.order_line.product_packaging_id.id, packaging_pack_of_20.id)
def test_compute_packaging_01(self):
"""Create a SO and use packaging in a multicompany environment.
Ensure any suggested packaging matches the SO's.
"""
company2 = self.env['res.company'].create([{'name': 'Company 2'}])
generic_single_pack = self.env['product.packaging'].create({
'name': "single pack",
'product_id': self.product.id,
'qty': 1.0,
'company_id': False,
})
company2_pack_of_10 = self.env['product.packaging'].create({
'name': "pack of 10 by Company 2",
'product_id': self.product.id,
'qty': 10.0,
'company_id': company2.id,
})
so1 = self.empty_order
so1_form = Form(so1)
with so1_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 10.0
so1_form.save()
self.assertEqual(so1.order_line.product_packaging_id, generic_single_pack)
self.assertEqual(so1.order_line.product_packaging_qty, 10.0)
so2 = self.env['sale.order'].with_company(company2).create({
'partner_id': self.partner.id,
})
so2_form = Form(so2)
with so2_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 10.0
so2_form.save()
self.assertEqual(so2.order_line.product_packaging_id, company2_pack_of_10)
self.assertEqual(so2.order_line.product_packaging_qty, 1.0)
def _create_sale_order(self):
"""Create dummy sale order (without lines)"""
return self.env['sale.order'].with_context(
default_sale_order_template_id=False
# Do not modify test behavior even if sale_management is installed
).create({
'partner_id': self.partner.id,
})
def test_invoicing_terms(self):
# Enable invoicing terms
self.env['ir.config_parameter'].sudo().set_param('account.use_invoice_terms', True)
# Plain invoice terms
self.env.company.terms_type = 'plain'
self.env.company.invoice_terms = "Coin coin"
sale_order = self._create_sale_order()
self.assertEqual(sale_order.note, "<p>Coin coin</p>")
# Html invoice terms (/terms page)
self.env.company.terms_type = 'html'
sale_order = self._create_sale_order()
self.assertTrue(sale_order.note.startswith("<p>Terms &amp; Conditions: "))
def test_validity_days(self):
self.env.company.quotation_validity_days = 5
with freeze_time("2020-05-02"):
sale_order = self._create_sale_order()
self.assertEqual(sale_order.validity_date, fields.Date.today() + timedelta(days=5))
self.env.company.quotation_validity_days = 0
sale_order = self._create_sale_order()
self.assertFalse(
sale_order.validity_date,
"No validity date must be specified if the company validity duration is 0")
def test_so_names(self):
"""Test custom context key for display_name & name_search.
Note: this key is used in sale_expense & sale_timesheet modules.
"""
SaleOrder = self.env['sale.order'].with_context(sale_show_partner_name=True)
res = SaleOrder.name_search(name=self.sale_order.partner_id.name)
self.assertEqual(res[0][0], self.sale_order.id)
self.assertNotIn(self.sale_order.partner_id.name, self.sale_order.display_name)
self.assertIn(
self.sale_order.partner_id.name,
self.sale_order.with_context(sale_show_partner_name=True).display_name)
def test_sol_names(self):
"""Check that the SOL description gets used for the display name."""
no_variant_attr = self.env['product.attribute'].create({
'name': "Attribute",
'create_variant': 'no_variant',
'value_ids': [
Command.create({'name': "Value 1", 'sequence': 1}),
Command.create({'name': "Value 2", 'sequence': 2}),
],
})
no_variant_product_tmpl = self.env['product.template'].create({
'name': "No Variant",
'attribute_line_ids': [Command.create({
'attribute_id': no_variant_attr.id,
'value_ids': no_variant_attr.value_ids.ids,
})],
})
no_variant_product = no_variant_product_tmpl.product_variant_id
ptals = no_variant_product_tmpl.valid_product_template_attribute_line_ids
ptav1 = next(iter(ptals.product_template_value_ids))
product_with_desc = self.env['product.product'].create({
'name': "Product with description",
'description_sale': "Additional\ninfo.",
})
self.sale_order.order_line = [
Command.create({'is_downpayment': True}),
Command.create({'display_type': 'line_note', 'name': "Foo\nBar\nBaz"}),
Command.create({
'product_id': no_variant_product.id,
'product_no_variant_attribute_value_ids': ptav1.ids,
}),
Command.create({'product_id': product_with_desc.id}),
]
sol1, sol2, sol3, sol4, sol5, sol6 = self.sale_order.order_line
sol1.name += "\nOK THANK YOU\nGOOD BYE"
self.assertEqual(
sol1.display_name,
f"{self.sale_order.name} - OK THANK YOU ({self.partner.name})",
"Product line with a custom description should display the first line of description",
)
self.assertEqual(
sol2.display_name,
f"{self.sale_order.name} - {sol2.product_id.display_name} ({self.partner.name})",
"Product line without description should display the product name",
)
self.assertEqual(
sol3.display_name,
f"{self.sale_order.name} - {sol3.name} ({self.partner.name})",
"Down payment line should display the down payment name",
)
self.assertEqual(
sol4.display_name,
f"{self.sale_order.name} - Foo ({self.partner.name})",
"Multi-line note should display the first line only",
)
self.assertIn(f"{no_variant_attr.name}: {ptav1.name}", sol5.name.split('\n'))
self.assertEqual(
sol5.display_name,
f"{self.sale_order.name} - {no_variant_product.name} ({self.partner.name})",
"Lines with attribute-based descriptions should display the product name",
)
self.assertEqual(
sol6.display_name,
f"{self.sale_order.name} - {product_with_desc.display_name} ({self.partner.name})",
"Product lines with standard sales description should display the product name",
)
def test_state_changes(self):
"""Test some untested state changes methods & logic."""
self.sale_order.action_quotation_sent()
self.assertEqual(self.sale_order.state, 'sent')
self.assertIn(self.sale_order.partner_id, self.sale_order.message_follower_ids.partner_id)
self.env.user.groups_id += self.env.ref('sale.group_auto_done_setting')
self.sale_order.action_confirm()
self.assertEqual(self.sale_order.state, 'sale')
self.assertTrue(self.sale_order.locked)
with self.assertRaises(UserError):
self.sale_order.action_confirm()
self.sale_order.action_unlock()
self.assertEqual(self.sale_order.state, 'sale')
def test_sol_name_search(self):
# Shouldn't raise
self.env['sale.order']._search([('order_line', 'ilike', 'product')])
name_search_data = self.env['sale.order.line'].name_search(name=self.sale_order.name)
sol_ids_found = dict(name_search_data).keys()
self.assertEqual(list(sol_ids_found), self.sale_order.order_line.ids)
def test_zero_quantity(self):
"""
If the quantity set is 0 it should remain to 0
Test that changing the uom do not change the quantity
"""
order_line = self.sale_order.order_line[0]
order_line.product_uom_qty = 0.0
order_line.product_uom = self.uom_dozen
self.assertEqual(order_line.product_uom_qty, 0.0)
def test_discount_rounding(self):
"""
Check the discount is properly rounded and the price subtotal
computed with this rounded discount
"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 192,
'discount': 74.246,
})]
})
self.assertEqual(sale_order.order_line.price_subtotal, 49.44, "Subtotal should be equal to 192 * (1 - 0.7425)")
self.assertEqual(sale_order.order_line.discount, 74.25)
def test_tax_amount_rounding(self):
""" Check order amounts are rounded according to settings """
tax_a = self.env['account.tax'].create({
'name': 'Test tax',
'type_tax_use': 'sale',
'price_include_override': 'tax_excluded',
'amount_type': 'percent',
'amount': 15.0,
})
# Test Round per Line (default)
self.env.company.tax_calculation_rounding_method = 'round_per_line'
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 6.7,
'discount': 0,
'tax_id': tax_a.ids,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 6.7,
'discount': 0,
'tax_id': tax_a.ids,
}),
],
})
self.assertEqual(sale_order.amount_total, 15.42, "")
# Test Round Globally
self.env.company.tax_calculation_rounding_method = 'round_globally'
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 6.7,
'discount': 0,
'tax_id': tax_a.ids,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 6.7,
'discount': 0,
'tax_id': tax_a.ids,
}),
],
})
self.assertEqual(sale_order.amount_total, 15.41, "")
def test_order_auto_lock_with_public_user(self):
public_user = self.env.ref('base.public_user')
self.sale_order.create_uid.groups_id += self.env.ref('sale.group_auto_done_setting')
self.sale_order.with_user(public_user.id).sudo().action_confirm()
self.assertFalse(public_user.has_group('sale.group_auto_done_setting'))
self.assertTrue(self.sale_order.locked)
def test_draft_quotation_followers(self):
sale_order = self.env['sale.order'].create({
'partner_id': self.partner1.id,
})
sale_order.partner_id = self.partner2
self.assertNotIn(self.partner2, sale_order.message_partner_ids)
def test_sent_quotation_followers(self):
sale_order = self.env['sale.order'].create({
'partner_id': self.partner1.id,
})
sale_order.action_quotation_sent()
sale_order.partner_id = self.partner2
self.assertIn(self.partner2, sale_order.message_partner_ids)
def test_scheduled_mark_so_as_sent(self):
"""Check that a order gets marked as sent after a scheduled message was sent."""
order = self.sale_order
composer = self.env['mail.compose.message'].with_context(
active_id=order.id,
active_ids=order.ids,
active_model=order._name,
mark_so_as_sent=True,
).new({'body': '<h1>Your Sales Order</h1>'})
composer.action_schedule_message(
scheduled_date=fields.Datetime.now() + timedelta(hours=1),
)
scheduled_message = self.env['mail.scheduled.message'].search([
('model', '=', order._name),
('res_id', '=', order.id),
], limit=1)
self.assertEqual(order.state, 'draft')
scheduled_message.post_message()
self.assertEqual(order.state, 'sent')
def test_so_discount_is_not_reset(self):
""" Discounts should not be recomputed on order confirmation """
with patch(
'odoo.addons.sale.models.sale_order_line.SaleOrderLine'
'._compute_discount'
) as patched:
self.sale_order.action_confirm()
self.sale_order.order_line.flush_recordset(['discount'])
patched.assert_not_called()
def test_so_company_empty(self):
"""Check emptying company on SO form"""
company_2 = self.env['res.company'].create({
'name': 'Company 2'
})
self.env.companies = [self.env.company, company_2]
so_form = Form(self.env['sale.order'])
with self.assertRaises(ValidationError):
so_form.company_id = self.env['res.company']
def test_so_is_not_invoiceable_if_only_discount_line_is_to_invoice(self):
self.sale_order.order_line.product_id.invoice_policy = 'delivery'
self.sale_order.action_confirm()
self.assertEqual(self.sale_order.invoice_status, 'no')
standard_lines = self.sale_order.order_line
self.env['sale.order.discount'].create({
'sale_order_id': self.sale_order.id,
'discount_amount': 33,
'discount_type': 'amount',
}).action_apply_discount()
# Only the discount line is invoiceable (there are lines not invoiced and not invoiceable)
discount_line = self.sale_order.order_line - standard_lines
self.assertEqual(discount_line.invoice_status, 'to invoice')
self.assertEqual(self.sale_order.invoice_status, 'no')
def test_so_is_invoiceable_if_only_discount_line_remains_to_invoice(self):
self.sale_order.order_line.product_id.invoice_policy = 'delivery'
self.sale_order.action_confirm()
self.assertEqual(self.sale_order.invoice_status, 'no')
standard_lines = self.sale_order.order_line
for sol in standard_lines:
sol.qty_delivered = sol.product_uom_qty
self.sale_order._create_invoices()
self.assertEqual(self.sale_order.invoice_status, 'invoiced')
self.env['sale.order.discount'].create({
'sale_order_id': self.sale_order.id,
'discount_amount': 33,
'discount_type': 'amount',
}).action_apply_discount()
# Only the discount line is invoiceable (there are no other lines remaining to invoice)
discount_line = (self.sale_order.order_line - standard_lines)
self.assertEqual(discount_line.invoice_status, 'to invoice')
self.assertEqual(self.sale_order.invoice_status, 'to invoice')
def test_sale_order_line_product_taxes_on_branch(self):
""" Check taxes populated on SO lines from product on branch company.
Taxes from the branch company should be taken with a fallback on parent company.
"""
# create the following branch hierarchy:
# Parent company
# |----> Branch X
# |----> Branch XX
company = self.env.company
branch_x = self.env['res.company'].create({
'name': 'Branch X',
'country_id': company.country_id.id,
'parent_id': company.id,
})
branch_xx = self.env['res.company'].create({
'name': 'Branch XX',
'country_id': company.country_id.id,
'parent_id': branch_x.id,
})
# create taxes for the parent company and its branches
tax_groups = self.env['account.tax.group'].create([{
'name': 'Tax Group',
'company_id': company.id,
}, {
'name': 'Tax Group X',
'company_id': branch_x.id,
}, {
'name': 'Tax Group XX',
'company_id': branch_xx.id,
}])
tax_a = self.env['account.tax'].create({
'name': 'Tax A',
'type_tax_use': 'sale',
'amount_type': 'percent',
'amount': 10,
'tax_group_id': tax_groups[0].id,
'company_id': company.id,
})
tax_b = self.env['account.tax'].create({
'name': 'Tax B',
'type_tax_use': 'sale',
'amount_type': 'percent',
'amount': 15,
'tax_group_id': tax_groups[0].id,
'company_id': company.id,
})
tax_x = self.env['account.tax'].create({
'name': 'Tax X',
'type_tax_use': 'sale',
'amount_type': 'percent',
'amount': 20,
'tax_group_id': tax_groups[1].id,
'company_id': branch_x.id,
})
tax_xx = self.env['account.tax'].create({
'name': 'Tax XX',
'type_tax_use': 'sale',
'amount_type': 'percent',
'amount': 25,
'tax_group_id': tax_groups[2].id,
'company_id': branch_xx.id,
})
# create several products with different taxes combination
product_all_taxes = self.env['product.product'].create({
'name': 'Product all taxes',
'taxes_id': [Command.set((tax_a + tax_b + tax_x + tax_xx).ids)],
})
product_no_xx_tax = self.env['product.product'].create({
'name': 'Product no tax from XX',
'taxes_id': [Command.set((tax_a + tax_b + tax_x).ids)],
})
product_no_branch_tax = self.env['product.product'].create({
'name': 'Product no tax from branch',
'taxes_id': [Command.set((tax_a + tax_b).ids)],
})
product_no_tax = self.env['product.product'].create({
'name': 'Product no tax',
'taxes_id': [],
})
# create a SO from Branch XX
so_form = Form(self.env['sale.order'].with_company(branch_xx))
so_form.partner_id = self.partner
# add 4 SO lines with the different products:
# - Product all taxes => tax from Branch XX should be set
# - Product no tax from XX => tax from Branch X should be set
# - Product no tax from branch => 2 taxes from parent company should be set
# - Product no tax => no tax should be set
with so_form.order_line.new() as line:
line.product_id = product_all_taxes
with so_form.order_line.new() as line:
line.product_id = product_no_xx_tax
with so_form.order_line.new() as line:
line.product_id = product_no_branch_tax
with so_form.order_line.new() as line:
line.product_id = product_no_tax
so = so_form.save()
self.assertRecordValues(so.order_line, [
{'product_id': product_all_taxes.id, 'tax_id': tax_xx.ids},
{'product_id': product_no_xx_tax.id, 'tax_id': tax_x.ids},
{'product_id': product_no_branch_tax.id, 'tax_id': (tax_a + tax_b).ids},
{'product_id': product_no_tax.id, 'tax_id': []},
])
def test_price_recomputation_on_readonly_unit_price(self):
"""Make sure that price computation works fine when unit price is readonly.
Since the client doesn't send readonly fields, flagging the field as readonly
will result in the `price_unit` being absent from the values, but not the
`technical_price_unit` field, which would disable the price computation.
This test makes sure that the `technical_price_unit` is correctly discarded
if not provided in the same request as the `price_unit`
"""
self.pricelist.item_ids = [
Command.create({
'product_id': self.product.id,
'fixed_price': 22.0,
'min_quantity': 3.0,
})
]
# Order update
product_sol = self.sale_order.order_line[0]
self.assertNotEqual(product_sol.price_unit, 22)
self.sale_order.write({
'order_line': [Command.update(
product_sol.id,
{'product_uom_qty': 4.0, 'technical_price_unit': 22.0}
)],
})
self.assertEqual(product_sol.price_unit, 22.0)
# Order creation
new_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom_qty': 5.0,
'technical_price_unit': 22.0,
}),
],
})
self.assertEqual(new_order.order_line.price_unit, 22.0)
def test_sale_order_unit_price_recompute_on_product_change(self):
"""Ensure price_unit is correctly recomputed when the product is
changed after manually changing the price.
"""
product2 = self.env['product.product'].create({
'name': "Test Product2",
'list_price': 0.0,
})
sol = self.sale_order.order_line[0]
# Manually change the product & price on the SO line
with Form(sol) as sol_form:
sol_form.product_id = product2
sol_form.price_unit = 100
# Expected price_subtotal = custom unit price * quantity
self.assertAlmostEqual(
sol.price_subtotal, 100 * sol.product_uom_qty,
msg="price_total should be equal to expected_total",
)
# Unit price should reset after changing the product
with Form(sol) as sol_form:
sol_form.product_id = self.product
# Expected price_subtotal = list price * quantity
self.assertAlmostEqual(
sol.price_subtotal, self.product.list_price * sol.product_uom_qty,
msg="price_total should be equal to expected_total",
)
def test_sale_order_email_subtitle(self):
"""Test email notification subtitle for Sale Order with and without partner name."""
partner = self.env['res.partner'].create({'type': 'invoice', 'parent_id': self.partner.id})
self.sale_order.partner_id = partner
context = self.sale_order._notify_by_email_prepare_rendering_context(message=self.env['mail.message'])
self.assertEqual(context['subtitles'][0], self.sale_order.name)
self.sale_order.partner_id.name = "Test Partner"
context = self.sale_order._notify_by_email_prepare_rendering_context(message=self.env['mail.message'])
self.assertEqual(context['subtitles'][0], f"{self.sale_order.name} - Test Partner")
@tagged('post_install', '-at_install')
class TestSaleOrderInvoicing(AccountTestInvoicingCommon, SaleCommon):
def test_invoice_state_when_ordered_quantity_is_negative(self):
"""When you invoice a SO line with a product that is invoiced on ordered quantities and has negative ordered quantity,
this test ensures that the invoicing status of the SO line is 'invoiced' (and not 'upselling')."""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': -1,
})]
})
sale_order.action_confirm()
sale_order._create_invoices(final=True)
self.assertTrue(sale_order.invoice_status == 'invoiced', 'Sale: The invoicing status of the SO should be "invoiced"')
@tagged('post_install', '-at_install')
class TestSalesTeam(SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
# set up users
cls.sale_team_2 = cls.env['crm.team'].create({
'name': 'Test Sales Team (2)',
})
cls.user_in_team = cls.env['res.users'].create({
'email': 'team0user@example.com',
'login': 'team0user',
'name': 'User in Team 0',
})
cls.sale_team.write({'member_ids': [4, cls.user_in_team.id]})
cls.user_not_in_team = cls.env['res.users'].create({
'email': 'noteamuser@example.com',
'login': 'noteamuser',
'name': 'User Not In Team',
})
def test_assign_sales_team_from_partner_user(self):
"""Use the team from the customer's sales person, if it is set"""
partner = self.env['res.partner'].create({
'name': 'Customer of User In Team',
'user_id': self.user_in_team.id,
})
sale_order = self.env['sale.order'].create({
'partner_id': partner.id,
})
self.assertEqual(sale_order.team_id.id, self.sale_team.id, 'Should assign to team of sales person')
def test_assign_sales_team_when_changing_user(self):
"""When we assign a sales person, change the team on the sales order to their team"""
sale_order = self.env['sale.order'].create({
'user_id': self.user_not_in_team.id,
'partner_id': self.partner.id,
'team_id': self.sale_team_2.id
})
sale_order.user_id = self.user_in_team
self.assertEqual(sale_order.team_id.id, self.sale_team.id, 'Should assign to team of sales person')
def test_keep_sales_team_when_changing_user_with_no_team(self):
"""When we assign a sales person that has no team, do not reset the team to default"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'team_id': self.sale_team_2.id
})
sale_order.user_id = self.user_not_in_team
self.assertEqual(sale_order.team_id.id, self.sale_team_2.id, 'Should not reset the team to default')
def test_sale_order_analytic_distribution_change(self):
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
analytic_plan = self.env['account.analytic.plan'].create({'name': 'Plan Test'})
analytic_account_super = self.env['account.analytic.account'].create({'name': 'Super Account', 'plan_id': analytic_plan.id})
analytic_account_great = self.env['account.analytic.account'].create({'name': 'Great Account', 'plan_id': analytic_plan.id})
super_product = self.env['product.product'].create({'name': 'Super Product'})
great_product = self.env['product.product'].create({'name': 'Great Product'})
product_no_account = self.env['product.product'].create({'name': 'Product No Account'})
self.env['account.analytic.distribution.model'].create([
{
'analytic_distribution': {analytic_account_super.id: 100},
'product_id': super_product.id,
},
{
'analytic_distribution': {analytic_account_great.id: 100},
'product_id': great_product.id,
},
])
partner = self.env['res.partner'].create({'name': 'Test Partner'})
sale_order = self.env['sale.order'].create({
'partner_id': partner.id,
})
sol = self.env['sale.order.line'].create({
'name': super_product.name,
'product_id': super_product.id,
'order_id': sale_order.id,
})
self.assertEqual(sol.analytic_distribution, {str(analytic_account_super.id): 100}, "The analytic distribution should be set to Super Account")
sol.write({'product_id': great_product.id})
self.assertEqual(sol.analytic_distribution, {str(analytic_account_great.id): 100}, "The analytic distribution should be set to Great Account")
so_no_analytic_account = self.env['sale.order'].create({
'partner_id': partner.id,
})
sol_no_analytic_account = self.env['sale.order.line'].create({
'name': super_product.name,
'product_id': super_product.id,
'order_id': so_no_analytic_account.id,
'analytic_distribution': False,
})
so_no_analytic_account.action_confirm()
self.assertFalse(sol_no_analytic_account.analytic_distribution, "The compute should not overwrite what the user has set.")
sale_order.action_confirm()
sol_on_confirmed_order = self.env['sale.order.line'].create({
'name': super_product.name,
'product_id': super_product.id,
'order_id': sale_order.id,
})
self.assertEqual(
sol_on_confirmed_order.analytic_distribution,
{str(analytic_account_super.id): 100},
"The analytic distribution should be set to Super Account, even for confirmed orders"
)
def test_cannot_assign_tax_of_mismatch_company(self):
""" Test that sol cannot have assigned tax belonging to a different company from that of the sale order. """
company_a = self.env['res.company'].create({'name': 'A'})
company_b = self.env['res.company'].create({'name': 'B'})
tax_group_a = self.env['account.tax.group'].create({'name': 'A', 'company_id': company_a.id})
tax_group_b = self.env['account.tax.group'].create({'name': 'B', 'company_id': company_b.id})
country = self.env['res.country'].search([], limit=1)
tax_a = self.env['account.tax'].create({
'name': 'A',
'amount': 10,
'company_id': company_a.id,
'tax_group_id': tax_group_a.id,
'country_id': country.id,
})
tax_b = self.env['account.tax'].create({
'name': 'B',
'amount': 10,
'company_id': company_b.id,
'tax_group_id': tax_group_b.id,
'country_id': country.id,
})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'company_id': company_a.id
})
product = self.env['product.product'].create({'name': 'Product'})
# In sudo to simulate an user that have access to both companies.
sol = self.env['sale.order.line'].sudo().create({
'name': product.name,
'product_id': product.id,
'order_id': sale_order.id,
'tax_id': tax_a,
})
with self.assertRaises(UserError):
sol.tax_id = tax_b
def test_assign_tax_multi_company(self):
root_company = self.env['res.company'].create({'name': 'B0 company'})
root_company.write({'child_ids': [
Command.create({'name': 'B1 company'}),
Command.create({'name': 'B2 company'}),
]})
country = self.env['res.country'].search([], limit=1)
basic_tax_group = self.env['account.tax.group'].create({'name': 'basic group', 'country_id': country.id})
tax_b0 = self.env['account.tax'].create({
'name': 'B0 tax',
'company_id': root_company.id,
'amount': 10,
'tax_group_id': basic_tax_group.id,
'country_id': country.id,
})
tax_b1 = self.env['account.tax'].create({
'name': 'B1 tax',
'company_id': root_company.child_ids[0].id,
'amount': 11,
'tax_group_id': basic_tax_group.id,
'country_id': country.id,
})
tax_b2 = self.env['account.tax'].create({
'name': 'B2 tax',
'company_id': root_company.child_ids[1].id,
'amount': 20,
'tax_group_id': basic_tax_group.id,
'country_id': country.id,
})
sale_order = self.env['sale.order'].create({'partner_id': self.partner.id, 'company_id': root_company.child_ids[0].id})
product = self.env['product.product'].create({'name': 'Product'})
# In sudo to simulate an user that have access to both companies.
sol_b1 = self.env['sale.order.line'].sudo().create({
'name': product.name,
'product_id': product.id,
'order_id': sale_order.id,
'tax_id': tax_b1,
})
# should not raise anything
sol_b1.tax_id = tax_b0
sol_b1.tax_id = tax_b1
# should raise (b2 is not on the same branch lineage as b1)
with self.assertRaises(UserError):
sol_b1.tax_id = tax_b2
def test_downpayment_amount_constraints(self):
"""Down payment amounts should be in the interval ]0, 1]."""
self.sale_order.require_payment = True
with self.assertRaises(ValidationError):
self.sale_order.prepayment_percent = -1
with self.assertRaises(ValidationError):
self.sale_order.prepayment_percent = 1.01
def test_qty_delivered_on_creation(self):
"""Checks that the qty delivered of sol is automatically set to 0.0 when an so is created"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.product.id,
})],
})
self.assertEqual(self.env['sale.order.line'].search(['&', ('order_id', '=', sale_order.id), ('qty_delivered', '=', 0.0)]), sale_order.order_line)
def test_action_recompute_taxes(self):
'''
This test verifies the taxes recomputation action that can be triggered
after updating the fiscal position on a sale order document.
'''
special_tax = self.env['account.tax'].create({
'name': "special_tax_10",
'amount_type': 'percent',
'amount': 25.0,
'include_base_amount': True,
'price_include_override': 'tax_included',
})
mapped_tax_a = self.env['account.tax'].create({
'name': "tax_a",
'amount_type': 'percent',
'amount': 12.5,
'include_base_amount': True,
'price_include_override': 'tax_included',
})
mapped_tax_b = self.env['account.tax'].create({
'name': "tax_b",
'amount_type': 'percent',
'amount': 5.0,
'include_base_amount': True,
'price_include_override': 'tax_included',
})
sales_tax = self.env['account.tax'].create({
'name': "VAT 20%",
'amount_type': 'percent',
'amount': 20.0,
'price_include_override': 'tax_included',
})
mapping_a = self.env['account.fiscal.position'].create({
'name': 'Special Tax Reduction',
'tax_ids': [Command.create({'tax_src_id': special_tax.id, 'tax_dest_id': mapped_tax_a.id})],
})
mapping_b = self.env['account.fiscal.position'].create({
'name': 'Special Tax Reduction',
'tax_ids': [Command.create({'tax_src_id': special_tax.id, 'tax_dest_id': mapped_tax_b.id})],
})
# taxes and standard price need to be set on the product, as they will be
# recomputed when changing the fiscal position.
self.product.write({
'lst_price': 300,
'taxes_id': [Command.set((special_tax + sales_tax).ids)],
})
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1.0,
}),
],
})
self.assertEqual(order.amount_total, 300)
self.assertEqual(order.amount_tax, 100)
order.fiscal_position_id = mapping_a
order._recompute_prices()
order.action_update_taxes()
self.assertEqual(order.amount_total, 270)
self.assertEqual(order.amount_tax, 70)
order.fiscal_position_id = mapping_b
order._recompute_prices()
order.action_update_taxes()
self.assertEqual(order.amount_total, 252)
self.assertEqual(order.amount_tax, 52)
@tagged('post_install', '-at_install')
class TestSaleMailComposerUI(MailCommon, HttpCase):
@classmethod
def setUpClass(cls):
super(TestSaleMailComposerUI, cls).setUpClass()
cls.env['mail.alias.domain'].create({'name': 'example.com'})
cls.partner = cls.env['res.partner'].create({
'name': 'test customer',
'email': 'dummy@example.com'
})
cls.quotation = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
})
def test_mail_attachment_removal_tour(self):
url = f"/odoo/sales/{self.quotation.id}"
with self.mock_mail_app():
self.start_tour(
url,
"mail_attachment_removal_tour",
login="admin",
)