summaryrefslogtreecommitdiff
path: root/addons/sale/tests
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/sale/tests
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/sale/tests')
-rw-r--r--addons/sale/tests/__init__.py14
-rw-r--r--addons/sale/tests/common.py199
-rw-r--r--addons/sale/tests/test_access_rights.py180
-rw-r--r--addons/sale/tests/test_onchange.py280
-rw-r--r--addons/sale/tests/test_reinvoice.py266
-rw-r--r--addons/sale/tests/test_sale_flow.py82
-rw-r--r--addons/sale/tests/test_sale_order.py584
-rw-r--r--addons/sale/tests/test_sale_pricelist.py163
-rw-r--r--addons/sale/tests/test_sale_product_attribute_value_config.py400
-rw-r--r--addons/sale/tests/test_sale_refund.py319
-rw-r--r--addons/sale/tests/test_sale_signature.py31
-rw-r--r--addons/sale/tests/test_sale_to_invoice.py390
-rw-r--r--addons/sale/tests/test_sale_transaction.py60
13 files changed, 2968 insertions, 0 deletions
diff --git a/addons/sale/tests/__init__.py b/addons/sale/tests/__init__.py
new file mode 100644
index 00000000..88bf481b
--- /dev/null
+++ b/addons/sale/tests/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_sale_product_attribute_value_config
+from . import test_sale_to_invoice
+from . import test_sale_order
+from . import test_sale_pricelist
+from . import test_onchange
+from . import test_reinvoice
+from . import test_access_rights
+from . import test_sale_refund
+from . import test_sale_signature
+from . import test_sale_transaction
+from . import test_sale_flow
diff --git a/addons/sale/tests/common.py b/addons/sale/tests/common.py
new file mode 100644
index 00000000..dfcd198e
--- /dev/null
+++ b/addons/sale/tests/common.py
@@ -0,0 +1,199 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+from odoo.addons.base.tests.common import SavepointCase
+
+
+class TestSaleCommonBase(SavepointCase):
+ ''' Setup with sale test configuration. '''
+
+ @classmethod
+ def setup_sale_configuration_for_company(cls, company):
+ Users = cls.env['res.users'].with_context(no_reset_password=True)
+
+ company_data = {
+ # Sales Team
+ 'default_sale_team': cls.env['crm.team'].with_context(tracking_disable=True).create({
+ 'name': 'Test Channel',
+ 'company_id': company.id,
+ }),
+
+ # Users
+ 'default_user_salesman': Users.create({
+ 'name': 'default_user_salesman',
+ 'login': 'default_user_salesman.comp%s' % company.id,
+ 'email': 'default_user_salesman@example.com',
+ 'signature': '--\nMark',
+ 'notification_type': 'email',
+ 'groups_id': [(6, 0, cls.env.ref('sales_team.group_sale_salesman').ids)],
+ 'company_ids': [(6, 0, company.ids)],
+ 'company_id': company.id,
+ }),
+ 'default_user_portal': Users.create({
+ 'name': 'default_user_portal',
+ 'login': 'default_user_portal.comp%s' % company.id,
+ 'email': 'default_user_portal@gladys.portal',
+ 'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])],
+ 'company_ids': [(6, 0, company.ids)],
+ 'company_id': company.id,
+ }),
+ 'default_user_employee': Users.create({
+ 'name': 'default_user_employee',
+ 'login': 'default_user_employee.comp%s' % company.id,
+ 'email': 'default_user_employee@example.com',
+ 'groups_id': [(6, 0, [cls.env.ref('base.group_user').id])],
+ 'company_ids': [(6, 0, company.ids)],
+ 'company_id': company.id,
+ }),
+
+ # Pricelist
+ 'default_pricelist': cls.env['product.pricelist'].with_company(company).create({
+ 'name': 'default_pricelist',
+ 'currency_id': company.currency_id.id,
+ }),
+
+ # Product category
+ 'product_category': cls.env['product.category'].with_company(company).create({
+ 'name': 'Test category',
+ }),
+ }
+
+ company_data.update({
+ # Products
+ 'product_service_delivery': cls.env['product.product'].with_company(company).create({
+ 'name': 'product_service_delivery',
+ 'categ_id': company_data['product_category'].id,
+ 'standard_price': 200.0,
+ 'list_price': 180.0,
+ 'type': 'service',
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
+ 'default_code': 'SERV_DEL',
+ 'invoice_policy': 'delivery',
+ 'taxes_id': [(6, 0, [])],
+ 'supplier_taxes_id': [(6, 0, [])],
+ }),
+ 'product_service_order': cls.env['product.product'].with_company(company).create({
+ 'name': 'product_service_order',
+ 'categ_id': company_data['product_category'].id,
+ 'standard_price': 40.0,
+ 'list_price': 90.0,
+ 'type': 'service',
+ 'uom_id': cls.env.ref('uom.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
+ 'description': 'Example of product to invoice on order',
+ 'default_code': 'PRE-PAID',
+ 'invoice_policy': 'order',
+ 'taxes_id': [(6, 0, [])],
+ 'supplier_taxes_id': [(6, 0, [])],
+ }),
+ 'product_order_cost': cls.env['product.product'].with_company(company).create({
+ 'name': 'product_order_cost',
+ 'categ_id': company_data['product_category'].id,
+ 'standard_price': 235.0,
+ 'list_price': 280.0,
+ 'type': 'consu',
+ 'weight': 0.01,
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
+ 'default_code': 'FURN_9999',
+ 'invoice_policy': 'order',
+ 'expense_policy': 'cost',
+ 'taxes_id': [(6, 0, [])],
+ 'supplier_taxes_id': [(6, 0, [])],
+ }),
+ 'product_delivery_cost': cls.env['product.product'].with_company(company).create({
+ 'name': 'product_delivery_cost',
+ 'categ_id': company_data['product_category'].id,
+ 'standard_price': 55.0,
+ 'list_price': 70.0,
+ 'type': 'consu',
+ 'weight': 0.01,
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
+ 'default_code': 'FURN_7777',
+ 'invoice_policy': 'delivery',
+ 'expense_policy': 'cost',
+ 'taxes_id': [(6, 0, [])],
+ 'supplier_taxes_id': [(6, 0, [])],
+ }),
+ 'product_order_sales_price': cls.env['product.product'].with_company(company).create({
+ 'name': 'product_order_sales_price',
+ 'categ_id': company_data['product_category'].id,
+ 'standard_price': 235.0,
+ 'list_price': 280.0,
+ 'type': 'consu',
+ 'weight': 0.01,
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
+ 'default_code': 'FURN_9999',
+ 'invoice_policy': 'order',
+ 'expense_policy': 'sales_price',
+ 'taxes_id': [(6, 0, [])],
+ 'supplier_taxes_id': [(6, 0, [])],
+ }),
+ 'product_delivery_sales_price': cls.env['product.product'].with_company(company).create({
+ 'name': 'product_delivery_sales_price',
+ 'categ_id': company_data['product_category'].id,
+ 'standard_price': 55.0,
+ 'list_price': 70.0,
+ 'type': 'consu',
+ 'weight': 0.01,
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
+ 'default_code': 'FURN_7777',
+ 'invoice_policy': 'delivery',
+ 'expense_policy': 'sales_price',
+ 'taxes_id': [(6, 0, [])],
+ 'supplier_taxes_id': [(6, 0, [])],
+ }),
+ 'product_order_no': cls.env['product.product'].with_company(company).create({
+ 'name': 'product_order_no',
+ 'categ_id': company_data['product_category'].id,
+ 'standard_price': 235.0,
+ 'list_price': 280.0,
+ 'type': 'consu',
+ 'weight': 0.01,
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
+ 'default_code': 'FURN_9999',
+ 'invoice_policy': 'order',
+ 'expense_policy': 'no',
+ 'taxes_id': [(6, 0, [])],
+ 'supplier_taxes_id': [(6, 0, [])],
+ }),
+ 'product_delivery_no': cls.env['product.product'].with_company(company).create({
+ 'name': 'product_delivery_no',
+ 'categ_id': company_data['product_category'].id,
+ 'standard_price': 55.0,
+ 'list_price': 70.0,
+ 'type': 'consu',
+ 'weight': 0.01,
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
+ 'default_code': 'FURN_7777',
+ 'invoice_policy': 'delivery',
+ 'expense_policy': 'no',
+ 'taxes_id': [(6, 0, [])],
+ 'supplier_taxes_id': [(6, 0, [])],
+ }),
+ })
+
+ return company_data
+
+
+class TestSaleCommon(AccountTestInvoicingCommon, TestSaleCommonBase):
+ ''' Setup to be used post-install with sale and accounting test configuration.'''
+
+ @classmethod
+ def setup_company_data(cls, company_name, chart_template=None, **kwargs):
+ company_data = super().setup_company_data(company_name, chart_template=chart_template, **kwargs)
+
+ company_data.update(cls.setup_sale_configuration_for_company(company_data['company']))
+
+ company_data['product_category'].write({
+ 'property_account_income_categ_id': company_data['default_account_revenue'].id,
+ 'property_account_expense_categ_id': company_data['default_account_expense'].id,
+ })
+
+ return company_data
diff --git a/addons/sale/tests/test_access_rights.py b/addons/sale/tests/test_access_rights.py
new file mode 100644
index 00000000..c6e52970
--- /dev/null
+++ b/addons/sale/tests/test_access_rights.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo.addons.sale.tests.common import TestSaleCommon
+from odoo.exceptions import AccessError, UserError, ValidationError
+from odoo.tests import HttpCase, tagged
+
+
+@tagged('post_install', '-at_install')
+class TestAccessRights(TestSaleCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ cls.company_data['default_user_salesman_2'] = cls.env['res.users'].with_context(no_reset_password=True).create({
+ 'name': 'default_user_salesman_2',
+ 'login': 'default_user_salesman_2.comp%s' % cls.company_data['company'].id,
+ 'email': 'default_user_salesman_2@example.com',
+ 'signature': '--\nMark',
+ 'notification_type': 'email',
+ 'groups_id': [(6, 0, cls.env.ref('sales_team.group_sale_salesman').ids)],
+ 'company_ids': [(6, 0, cls.company_data['company'].ids)],
+ 'company_id': cls.company_data['company'].id,
+ })
+
+ # Create the SO with a specific salesperson
+ cls.order = cls.env['sale.order'].with_context(tracking_disable=True).create({
+ 'partner_id': cls.partner_a.id,
+ 'user_id': cls.company_data['default_user_salesman'].id
+ })
+
+ def test_access_sales_manager(self):
+ """ Test sales manager's access rights """
+ SaleOrder = self.env['sale.order'].with_context(tracking_disable=True)
+ # Manager can see the SO which is assigned to another salesperson
+ self.order.read()
+ # Manager can change a salesperson of the SO
+ self.order.write({'user_id': self.company_data['default_user_salesman'].id})
+ # Manager can create the SO for other salesperson
+ sale_order = SaleOrder.create({
+ 'partner_id': self.partner_a.id,
+ 'user_id': self.company_data['default_user_salesman'].id
+ })
+ self.assertIn(sale_order.id, SaleOrder.search([]).ids, 'Sales manager should be able to create the SO of other salesperson')
+ # Manager can confirm the SO
+ sale_order.action_confirm()
+ # Manager can not delete confirmed SO
+ with self.assertRaises(UserError):
+ sale_order.unlink()
+ # Manager can delete the SO of other salesperson if SO is in 'draft' or 'cancel' state
+ self.order.unlink()
+ self.assertNotIn(self.order.id, SaleOrder.search([]).ids, 'Sales manager should be able to delete the SO')
+
+ # Manager can create a Sales Team
+ india_channel = self.env['crm.team'].with_context(tracking_disable=True).create({
+ 'name': 'India',
+ })
+ self.assertIn(india_channel.id, self.env['crm.team'].search([]).ids, 'Sales manager should be able to create a Sales Team')
+ # Manager can edit a Sales Team
+ india_channel.write({'name': 'new_india'})
+ self.assertEqual(india_channel.name, 'new_india', 'Sales manager should be able to edit a Sales Team')
+ # Manager can delete a Sales Team
+ india_channel.unlink()
+ self.assertNotIn(india_channel.id, self.env['crm.team'].search([]).ids, 'Sales manager should be able to delete a Sales Team')
+
+ def test_access_sales_person(self):
+ """ Test Salesperson's access rights """
+ # Salesperson can see only their own sales order
+ with self.assertRaises(AccessError):
+ self.order.with_user(self.company_data['default_user_salesman_2']).read()
+ # Now assign the SO to themselves
+ self.order.write({'user_id': self.company_data['default_user_salesman_2'].id})
+ self.order.with_user(self.company_data['default_user_salesman_2']).read()
+ # Salesperson can change a Sales Team of SO
+ self.order.with_user(self.company_data['default_user_salesman_2']).write({'team_id': self.company_data['default_sale_team'].id})
+ # Salesperson can't create the SO of other salesperson
+ with self.assertRaises(AccessError):
+ self.env['sale.order'].with_user(self.company_data['default_user_salesman_2']).create({
+ 'partner_id': self.partner_a.id,
+ 'user_id': self.company_data['default_user_salesman'].id
+ })
+ # Salesperson can't delete the SO
+ with self.assertRaises(AccessError):
+ self.order.with_user(self.company_data['default_user_salesman_2']).unlink()
+ # Salesperson can confirm the SO
+ self.order.with_user(self.company_data['default_user_salesman_2']).action_confirm()
+
+ def test_access_portal_user(self):
+ """ Test portal user's access rights """
+ # Portal user can see the confirmed SO for which they are assigned as a customer
+ with self.assertRaises(AccessError):
+ self.order.with_user(self.company_data['default_user_portal']).read()
+
+ self.order.partner_id = self.company_data['default_user_portal'].partner_id
+ self.order.action_confirm()
+ # Portal user can't edit the SO
+ with self.assertRaises(AccessError):
+ self.order.with_user(self.company_data['default_user_portal']).write({'team_id': self.company_data['default_sale_team'].id})
+ # Portal user can't create the SO
+ with self.assertRaises(AccessError):
+ self.env['sale.order'].with_user(self.company_data['default_user_portal']).create({
+ 'partner_id': self.partner_a.id,
+ })
+ # Portal user can't delete the SO which is in 'draft' or 'cancel' state
+ self.order.action_cancel()
+ with self.assertRaises(AccessError):
+ self.order.with_user(self.company_data['default_user_portal']).unlink()
+
+ def test_access_employee(self):
+ """ Test classic employee's access rights """
+ # Employee can't see any SO
+ with self.assertRaises(AccessError):
+ self.order.with_user(self.company_data['default_user_employee']).read()
+ # Employee can't edit the SO
+ with self.assertRaises(AccessError):
+ self.order.with_user(self.company_data['default_user_employee']).write({'team_id': self.company_data['default_sale_team'].id})
+ # Employee can't create the SO
+ with self.assertRaises(AccessError):
+ self.env['sale.order'].with_user(self.company_data['default_user_employee']).create({
+ 'partner_id': self.partner_a.id,
+ })
+ # Employee can't delete the SO
+ with self.assertRaises(AccessError):
+ self.order.with_user(self.company_data['default_user_employee']).unlink()
+
+@tagged('post_install', '-at_install')
+class TestAccessRightsControllers(HttpCase):
+
+ def test_access_controller(self):
+
+ portal_so = self.env.ref("sale.portal_sale_order_2").sudo()
+ portal_so._portal_ensure_token()
+ token = portal_so.access_token
+
+ private_so = self.env.ref("sale.sale_order_1")
+
+ self.authenticate(None, None)
+
+ # Test public user can't print an order without a token
+ req = self.url_open(
+ url='/my/orders/%s?report_type=pdf' % portal_so.id,
+ allow_redirects=False,
+ )
+ self.assertEqual(req.status_code, 302)
+
+ # or with a random token
+ req = self.url_open(
+ url='/my/orders/%s?access_token=%s&report_type=pdf' % (
+ portal_so.id,
+ "foo",
+ ),
+ allow_redirects=False,
+ )
+ self.assertEqual(req.status_code, 302)
+
+ # but works fine with the right token
+ req = self.url_open(
+ url='/my/orders/%s?access_token=%s&report_type=pdf' % (
+ portal_so.id,
+ token,
+ ),
+ allow_redirects=False,
+ )
+ self.assertEqual(req.status_code, 200)
+
+ self.authenticate("portal", "portal")
+
+ # do not need the token when logged in
+ req = self.url_open(
+ url='/my/orders/%s?report_type=pdf' % portal_so.id,
+ allow_redirects=False,
+ )
+ self.assertEqual(req.status_code, 200)
+
+ # but still can't access another order
+ req = self.url_open(
+ url='/my/orders/%s?report_type=pdf' % private_so.id,
+ allow_redirects=False,
+ )
+ self.assertEqual(req.status_code, 302)
diff --git a/addons/sale/tests/test_onchange.py b/addons/sale/tests/test_onchange.py
new file mode 100644
index 00000000..5c8eb185
--- /dev/null
+++ b/addons/sale/tests/test_onchange.py
@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import Form
+from odoo.tests.common import TransactionCase
+
+
+class TestOnchangeProductId(TransactionCase):
+ """Test that when an included tax is mapped by a fiscal position, the included tax must be
+ subtracted to the price of the product.
+ """
+
+ def setUp(self):
+ super(TestOnchangeProductId, self).setUp()
+ self.fiscal_position_model = self.env['account.fiscal.position']
+ self.fiscal_position_tax_model = self.env['account.fiscal.position.tax']
+ self.tax_model = self.env['account.tax']
+ self.so_model = self.env['sale.order']
+ self.po_line_model = self.env['sale.order.line']
+ self.res_partner_model = self.env['res.partner']
+ self.product_tmpl_model = self.env['product.template']
+ self.product_model = self.env['product.product']
+ self.product_uom_model = self.env['uom.uom']
+ self.supplierinfo_model = self.env["product.supplierinfo"]
+ self.pricelist_model = self.env['product.pricelist']
+
+ def test_onchange_product_id(self):
+
+ uom_id = self.product_uom_model.search([('name', '=', 'Units')])[0]
+ pricelist = self.pricelist_model.search([('name', '=', 'Public Pricelist')])[0]
+
+ partner_id = self.res_partner_model.create(dict(name="George"))
+ tax_include_id = self.tax_model.create(dict(name="Include tax",
+ amount='21.00',
+ price_include=True,
+ type_tax_use='sale'))
+ tax_exclude_id = self.tax_model.create(dict(name="Exclude tax",
+ amount='0.00',
+ type_tax_use='sale'))
+
+ product_tmpl_id = self.product_tmpl_model.create(dict(name="Voiture",
+ list_price=121,
+ taxes_id=[(6, 0, [tax_include_id.id])]))
+
+ product_id = product_tmpl_id.product_variant_id
+
+ fp_id = self.fiscal_position_model.create(dict(name="fiscal position", sequence=1))
+
+ fp_tax_id = self.fiscal_position_tax_model.create(dict(position_id=fp_id.id,
+ tax_src_id=tax_include_id.id,
+ tax_dest_id=tax_exclude_id.id))
+
+ # Create the SO with one SO line and apply a pricelist and fiscal position on it
+ order_form = Form(self.env['sale.order'].with_context(tracking_disable=True))
+ order_form.partner_id = partner_id
+ order_form.pricelist_id = pricelist
+ order_form.fiscal_position_id = fp_id
+ with order_form.order_line.new() as line:
+ line.name = product_id.name
+ line.product_id = product_id
+ line.product_uom_qty = 1.0
+ line.product_uom = uom_id
+ sale_order = order_form.save()
+
+ # Check the unit price of SO line
+ self.assertEqual(100, sale_order.order_line[0].price_unit, "The included tax must be subtracted to the price")
+
+ def test_pricelist_application(self):
+ """ Test different prices are correctly applied based on dates """
+ support_product = self.env['product.product'].create({
+ 'name': 'Virtual Home Staging',
+ 'list_price': 100,
+ })
+ partner = self.res_partner_model.create(dict(name="George"))
+
+ christmas_pricelist = self.env['product.pricelist'].create({
+ 'name': 'Christmas pricelist',
+ 'item_ids': [(0, 0, {
+ 'date_start': "2017-12-01",
+ 'date_end': "2017-12-24",
+ 'compute_price': 'percentage',
+ 'base': 'list_price',
+ 'percent_price': 20,
+ 'applied_on': '3_global',
+ 'name': 'Pre-Christmas discount'
+ }), (0, 0, {
+ 'date_start': "2017-12-25",
+ 'date_end': "2017-12-31",
+ 'compute_price': 'percentage',
+ 'base': 'list_price',
+ 'percent_price': 50,
+ 'applied_on': '3_global',
+ 'name': 'Post-Christmas super-discount'
+ })]
+ })
+
+ # Create the SO with pricelist based on date
+ order_form = Form(self.env['sale.order'].with_context(tracking_disable=True))
+ order_form.partner_id = partner
+ order_form.date_order = '2017-12-20'
+ order_form.pricelist_id = christmas_pricelist
+ with order_form.order_line.new() as line:
+ line.product_id = support_product
+ so = order_form.save()
+ # Check the unit price and subtotal of SO line
+ self.assertEqual(so.order_line[0].price_unit, 80, "First date pricelist rule not applied")
+ self.assertEqual(so.order_line[0].price_subtotal, so.order_line[0].price_unit * so.order_line[0].product_uom_qty, 'Total of SO line should be a multiplication of unit price and ordered quantity')
+
+ # Change order date of the SO and check the unit price and subtotal of SO line
+ with Form(so) as order:
+ order.date_order = '2017-12-30'
+ with order.order_line.edit(0) as line:
+ line.product_id = support_product
+
+ self.assertEqual(so.order_line[0].price_unit, 50, "Second date pricelist rule not applied")
+ self.assertEqual(so.order_line[0].price_subtotal, so.order_line[0].price_unit * so.order_line[0].product_uom_qty, 'Total of SO line should be a multiplication of unit price and ordered quantity')
+
+ def test_pricelist_uom_discount(self):
+ """ Test prices and discounts are correctly applied based on date and uom"""
+ computer_case = self.env['product.product'].create({
+ 'name': 'Drawer Black',
+ 'list_price': 100,
+ })
+ partner = self.res_partner_model.create(dict(name="George"))
+ categ_unit_id = self.ref('uom.product_uom_categ_unit')
+ goup_discount_id = self.ref('product.group_discount_per_so_line')
+ self.env.user.write({'groups_id': [(4, goup_discount_id, 0)]})
+ new_uom = self.env['uom.uom'].create({
+ 'name': '10 units',
+ 'factor_inv': 10,
+ 'uom_type': 'bigger',
+ 'rounding': 1.0,
+ 'category_id': categ_unit_id
+ })
+ christmas_pricelist = self.env['product.pricelist'].create({
+ 'name': 'Christmas pricelist',
+ 'discount_policy': 'without_discount',
+ 'item_ids': [(0, 0, {
+ 'date_start': "2017-12-01",
+ 'date_end': "2017-12-30",
+ 'compute_price': 'percentage',
+ 'base': 'list_price',
+ 'percent_price': 10,
+ 'applied_on': '3_global',
+ 'name': 'Christmas discount'
+ })]
+ })
+
+ so = self.env['sale.order'].create({
+ 'partner_id': partner.id,
+ 'date_order': '2017-12-20',
+ 'pricelist_id': christmas_pricelist.id,
+ })
+
+ order_line = self.env['sale.order.line'].new({
+ 'order_id': so.id,
+ 'product_id': computer_case.id,
+ })
+
+ # force compute uom and prices
+ order_line.product_id_change()
+ order_line.product_uom_change()
+ order_line._onchange_discount()
+ self.assertEqual(order_line.price_subtotal, 90, "Christmas discount pricelist rule not applied")
+ self.assertEqual(order_line.discount, 10, "Christmas discount not equalt to 10%")
+ order_line.product_uom = new_uom
+ order_line.product_uom_change()
+ order_line._onchange_discount()
+ self.assertEqual(order_line.price_subtotal, 900, "Christmas discount pricelist rule not applied")
+ self.assertEqual(order_line.discount, 10, "Christmas discount not equalt to 10%")
+
+ def test_pricelist_based_on_other(self):
+ """ Test price and discount are correctly applied with a pricelist based on an other one"""
+ computer_case = self.env['product.product'].create({
+ 'name': 'Drawer Black',
+ 'list_price': 100,
+ })
+ partner = self.res_partner_model.create(dict(name="George"))
+ goup_discount_id = self.ref('product.group_discount_per_so_line')
+ self.env.user.write({'groups_id': [(4, goup_discount_id, 0)]})
+
+ first_pricelist = self.env['product.pricelist'].create({
+ 'name': 'First pricelist',
+ 'discount_policy': 'without_discount',
+ 'item_ids': [(0, 0, {
+ 'compute_price': 'percentage',
+ 'base': 'list_price',
+ 'percent_price': 10,
+ 'applied_on': '3_global',
+ 'name': 'First discount'
+ })]
+ })
+
+ second_pricelist = self.env['product.pricelist'].create({
+ 'name': 'Second pricelist',
+ 'discount_policy': 'without_discount',
+ 'item_ids': [(0, 0, {
+ 'compute_price': 'formula',
+ 'base': 'pricelist',
+ 'base_pricelist_id': first_pricelist.id,
+ 'price_discount': 10,
+ 'applied_on': '3_global',
+ 'name': 'Second discount'
+ })]
+ })
+
+ so = self.env['sale.order'].create({
+ 'partner_id': partner.id,
+ 'date_order': '2018-07-11',
+ 'pricelist_id': second_pricelist.id,
+ })
+
+ order_line = self.env['sale.order.line'].new({
+ 'order_id': so.id,
+ 'product_id': computer_case.id,
+ })
+
+ # force compute uom and prices
+ order_line.product_id_change()
+ order_line._onchange_discount()
+ self.assertEqual(order_line.price_subtotal, 81, "Second pricelist rule not applied")
+ self.assertEqual(order_line.discount, 19, "Second discount not applied")
+
+ def test_pricelist_with_other_currency(self):
+ """ Test prices are correctly applied with a pricelist with an other currency"""
+ computer_case = self.env['product.product'].create({
+ 'name': 'Drawer Black',
+ 'list_price': 100,
+ })
+ computer_case.list_price = 100
+ partner = self.res_partner_model.create(dict(name="George"))
+ categ_unit_id = self.ref('uom.product_uom_categ_unit')
+ other_currency = self.env['res.currency'].create({'name': 'other currency',
+ 'symbol': 'other'})
+ self.env['res.currency.rate'].create({'name': '2018-07-11',
+ 'rate': 2.0,
+ 'currency_id': other_currency.id,
+ 'company_id': self.env.company.id})
+ self.env['res.currency.rate'].search(
+ [('currency_id', '=', self.env.company.currency_id.id)]
+ ).unlink()
+ new_uom = self.env['uom.uom'].create({
+ 'name': '10 units',
+ 'factor_inv': 10,
+ 'uom_type': 'bigger',
+ 'rounding': 1.0,
+ 'category_id': categ_unit_id
+ })
+
+ # This pricelist doesn't show the discount
+ first_pricelist = self.env['product.pricelist'].create({
+ 'name': 'First pricelist',
+ 'currency_id': other_currency.id,
+ 'discount_policy': 'with_discount',
+ 'item_ids': [(0, 0, {
+ 'compute_price': 'percentage',
+ 'base': 'list_price',
+ 'percent_price': 10,
+ 'applied_on': '3_global',
+ 'name': 'First discount'
+ })]
+ })
+
+ so = self.env['sale.order'].create({
+ 'partner_id': partner.id,
+ 'date_order': '2018-07-12',
+ 'pricelist_id': first_pricelist.id,
+ })
+
+ order_line = self.env['sale.order.line'].new({
+ 'order_id': so.id,
+ 'product_id': computer_case.id,
+ })
+
+ # force compute uom and prices
+ order_line.product_id_change()
+ self.assertEqual(order_line.price_unit, 180, "First pricelist rule not applied")
+ order_line.product_uom = new_uom
+ order_line.product_uom_change()
+ self.assertEqual(order_line.price_unit, 1800, "First pricelist rule not applied")
diff --git a/addons/sale/tests/test_reinvoice.py b/addons/sale/tests/test_reinvoice.py
new file mode 100644
index 00000000..0ecb15ed
--- /dev/null
+++ b/addons/sale/tests/test_reinvoice.py
@@ -0,0 +1,266 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo.addons.sale.tests.common import TestSaleCommon
+from odoo.tests import Form, tagged
+
+
+@tagged('post_install', '-at_install')
+class TestReInvoice(TestSaleCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ cls.analytic_account = cls.env['account.analytic.account'].create({
+ 'name': 'Test AA',
+ 'code': 'TESTSALE_REINVOICE',
+ 'company_id': cls.partner_a.company_id.id,
+ 'partner_id': cls.partner_a.id
+ })
+
+ cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
+ 'partner_id': cls.partner_a.id,
+ 'partner_invoice_id': cls.partner_a.id,
+ 'partner_shipping_id': cls.partner_a.id,
+ 'analytic_account_id': cls.analytic_account.id,
+ 'pricelist_id': cls.company_data['default_pricelist'].id,
+ })
+
+ cls.AccountMove = cls.env['account.move'].with_context(
+ default_move_type='in_invoice',
+ default_invoice_date=cls.sale_order.date_order,
+ mail_notrack=True,
+ mail_create_nolog=True,
+ )
+
+ def test_at_cost(self):
+ """ Test vendor bill at cost for product based on ordered and delivered quantities. """
+ # create SO line and confirm SO (with only one line)
+ sale_order_line1 = self.env['sale.order.line'].create({
+ 'name': self.company_data['product_order_cost'].name,
+ 'product_id': self.company_data['product_order_cost'].id,
+ 'product_uom_qty': 2,
+ 'qty_delivered': 1,
+ 'product_uom': self.company_data['product_order_cost'].uom_id.id,
+ 'price_unit': self.company_data['product_order_cost'].list_price,
+ 'order_id': self.sale_order.id,
+ })
+ sale_order_line1.product_id_change()
+ sale_order_line2 = self.env['sale.order.line'].create({
+ 'name': self.company_data['product_delivery_cost'].name,
+ 'product_id': self.company_data['product_delivery_cost'].id,
+ 'product_uom_qty': 4,
+ 'qty_delivered': 1,
+ 'product_uom': self.company_data['product_delivery_cost'].uom_id.id,
+ 'price_unit': self.company_data['product_delivery_cost'].list_price,
+ 'order_id': self.sale_order.id,
+ })
+ sale_order_line2.product_id_change()
+
+ self.sale_order.onchange_partner_id()
+ self.sale_order._compute_tax_id()
+ self.sale_order.action_confirm()
+
+ # create invoice lines and validate it
+ move_form = Form(self.AccountMove)
+ move_form.partner_id = self.partner_a
+ with move_form.line_ids.new() as line_form:
+ line_form.product_id = self.company_data['product_order_cost']
+ line_form.quantity = 3.0
+ line_form.analytic_account_id = self.analytic_account
+ with move_form.line_ids.new() as line_form:
+ line_form.product_id = self.company_data['product_delivery_cost']
+ line_form.quantity = 3.0
+ line_form.analytic_account_id = self.analytic_account
+ invoice_a = move_form.save()
+ invoice_a.action_post()
+
+ sale_order_line3 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol.product_id == self.company_data['product_order_cost'])
+ sale_order_line4 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol.product_id == self.company_data['product_delivery_cost'])
+
+ self.assertTrue(sale_order_line3, "A new sale line should have been created with ordered product")
+ self.assertTrue(sale_order_line4, "A new sale line should have been created with delivered product")
+ self.assertEqual(len(self.sale_order.order_line), 4, "There should be 4 lines on the SO (2 vendor bill lines created)")
+ self.assertEqual(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 2, "There should be 4 lines on the SO (2 vendor bill lines created)")
+
+ self.assertEqual((sale_order_line3.price_unit, sale_order_line3.qty_delivered, sale_order_line3.product_uom_qty, sale_order_line3.qty_invoiced), (self.company_data['product_order_cost'].standard_price, 3, 0, 0), 'Sale line is wrong after confirming vendor invoice')
+ self.assertEqual((sale_order_line4.price_unit, sale_order_line4.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line4.qty_invoiced), (self.company_data['product_delivery_cost'].standard_price, 3, 0, 0), 'Sale line is wrong after confirming vendor invoice')
+
+ self.assertEqual(sale_order_line3.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line should be computed by analytic amount")
+ self.assertEqual(sale_order_line4.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line should be computed by analytic amount")
+
+ # create second invoice lines and validate it
+ move_form = Form(self.AccountMove)
+ move_form.partner_id = self.partner_a
+ with move_form.line_ids.new() as line_form:
+ line_form.product_id = self.company_data['product_order_cost']
+ line_form.quantity = 2.0
+ line_form.analytic_account_id = self.analytic_account
+ with move_form.line_ids.new() as line_form:
+ line_form.product_id = self.company_data['product_delivery_cost']
+ line_form.quantity = 2.0
+ line_form.analytic_account_id = self.analytic_account
+ invoice_b = move_form.save()
+ invoice_b.action_post()
+
+ sale_order_line5 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol != sale_order_line3 and sol.product_id == self.company_data['product_order_cost'])
+ sale_order_line6 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol != sale_order_line4 and sol.product_id == self.company_data['product_delivery_cost'])
+
+ self.assertTrue(sale_order_line5, "A new sale line should have been created with ordered product")
+ self.assertTrue(sale_order_line6, "A new sale line should have been created with delivered product")
+
+ self.assertEqual(len(self.sale_order.order_line), 6, "There should be still 4 lines on the SO, no new created")
+ self.assertEqual(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 4, "There should be still 2 expenses lines on the SO")
+
+ self.assertEqual((sale_order_line5.price_unit, sale_order_line5.qty_delivered, sale_order_line5.product_uom_qty, sale_order_line5.qty_invoiced), (self.company_data['product_order_cost'].standard_price, 2, 0, 0), 'Sale line 5 is wrong after confirming 2e vendor invoice')
+ self.assertEqual((sale_order_line6.price_unit, sale_order_line6.qty_delivered, sale_order_line6.product_uom_qty, sale_order_line6.qty_invoiced), (self.company_data['product_delivery_cost'].standard_price, 2, 0, 0), 'Sale line 6 is wrong after confirming 2e vendor invoice')
+
+ def test_sales_price(self):
+ """ Test invoicing vendor bill at sales price for products based on delivered and ordered quantities. Check no existing SO line is incremented, but when invoicing a
+ second time, increment only the delivered so line.
+ """
+ # create SO line and confirm SO (with only one line)
+ sale_order_line1 = self.env['sale.order.line'].create({
+ 'name': self.company_data['product_delivery_sales_price'].name,
+ 'product_id': self.company_data['product_delivery_sales_price'].id,
+ 'product_uom_qty': 2,
+ 'qty_delivered': 1,
+ 'product_uom': self.company_data['product_delivery_sales_price'].uom_id.id,
+ 'price_unit': self.company_data['product_delivery_sales_price'].list_price,
+ 'order_id': self.sale_order.id,
+ })
+ sale_order_line1.product_id_change()
+ sale_order_line2 = self.env['sale.order.line'].create({
+ 'name': self.company_data['product_order_sales_price'].name,
+ 'product_id': self.company_data['product_order_sales_price'].id,
+ 'product_uom_qty': 3,
+ 'qty_delivered': 1,
+ 'product_uom': self.company_data['product_order_sales_price'].uom_id.id,
+ 'price_unit': self.company_data['product_order_sales_price'].list_price,
+ 'order_id': self.sale_order.id,
+ })
+ sale_order_line2.product_id_change()
+ self.sale_order._compute_tax_id()
+ self.sale_order.action_confirm()
+
+ # create invoice lines and validate it
+ move_form = Form(self.AccountMove)
+ move_form.partner_id = self.partner_a
+ with move_form.line_ids.new() as line_form:
+ line_form.product_id = self.company_data['product_delivery_sales_price']
+ line_form.quantity = 3.0
+ line_form.analytic_account_id = self.analytic_account
+ with move_form.line_ids.new() as line_form:
+ line_form.product_id = self.company_data['product_order_sales_price']
+ line_form.quantity = 3.0
+ line_form.analytic_account_id = self.analytic_account
+ invoice_a = move_form.save()
+ invoice_a.action_post()
+
+ sale_order_line3 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol.product_id == self.company_data['product_delivery_sales_price'])
+ sale_order_line4 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol.product_id == self.company_data['product_order_sales_price'])
+
+ self.assertTrue(sale_order_line3, "A new sale line should have been created with ordered product")
+ self.assertTrue(sale_order_line4, "A new sale line should have been created with delivered product")
+ self.assertEqual(len(self.sale_order.order_line), 4, "There should be 4 lines on the SO (2 vendor bill lines created)")
+ self.assertEqual(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 2, "There should be 4 lines on the SO (2 vendor bill lines created)")
+
+ self.assertEqual((sale_order_line3.price_unit, sale_order_line3.qty_delivered, sale_order_line3.product_uom_qty, sale_order_line3.qty_invoiced), (self.company_data['product_delivery_sales_price'].list_price, 3, 0, 0), 'Sale line is wrong after confirming vendor invoice')
+ self.assertEqual((sale_order_line4.price_unit, sale_order_line4.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line4.qty_invoiced), (self.company_data['product_order_sales_price'].list_price, 3, 0, 0), 'Sale line is wrong after confirming vendor invoice')
+
+ self.assertEqual(sale_order_line3.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line 3 should be computed by analytic amount")
+ self.assertEqual(sale_order_line4.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line 4 should be computed by analytic amount")
+
+ # create second invoice lines and validate it
+ move_form = Form(self.AccountMove)
+ move_form.partner_id = self.partner_a
+ with move_form.line_ids.new() as line_form:
+ line_form.product_id = self.company_data['product_delivery_sales_price']
+ line_form.quantity = 2.0
+ line_form.analytic_account_id = self.analytic_account
+ with move_form.line_ids.new() as line_form:
+ line_form.product_id = self.company_data['product_order_sales_price']
+ line_form.quantity = 2.0
+ line_form.analytic_account_id = self.analytic_account
+ invoice_b = move_form.save()
+ invoice_b.action_post()
+
+ sale_order_line5 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol != sale_order_line3 and sol.product_id == self.company_data['product_delivery_sales_price'])
+ sale_order_line6 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol != sale_order_line4 and sol.product_id == self.company_data['product_order_sales_price'])
+
+ self.assertFalse(sale_order_line5, "No new sale line should have been created with delivered product !!")
+ self.assertTrue(sale_order_line6, "A new sale line should have been created with ordered product")
+
+ self.assertEqual(len(self.sale_order.order_line), 5, "There should be 5 lines on the SO, 1 new created and 1 incremented")
+ self.assertEqual(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 3, "There should be 3 expenses lines on the SO")
+
+ self.assertEqual((sale_order_line6.price_unit, sale_order_line6.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line6.qty_invoiced), (self.company_data['product_order_sales_price'].list_price, 2, 0, 0), 'Sale line is wrong after confirming 2e vendor invoice')
+
+ def test_no_expense(self):
+ """ Test invoicing vendor bill with no policy. Check nothing happen. """
+ # confirm SO
+ sale_order_line = self.env['sale.order.line'].create({
+ 'name': self.company_data['product_delivery_no'].name,
+ 'product_id': self.company_data['product_delivery_no'].id,
+ 'product_uom_qty': 2,
+ 'qty_delivered': 1,
+ 'product_uom': self.company_data['product_delivery_no'].uom_id.id,
+ 'price_unit': self.company_data['product_delivery_no'].list_price,
+ 'order_id': self.sale_order.id,
+ })
+ self.sale_order._compute_tax_id()
+ self.sale_order.action_confirm()
+
+ # create invoice lines and validate it
+ move_form = Form(self.AccountMove)
+ move_form.partner_id = self.partner_a
+ with move_form.line_ids.new() as line_form:
+ line_form.product_id = self.company_data['product_delivery_no']
+ line_form.quantity = 3.0
+ line_form.analytic_account_id = self.analytic_account
+ invoice_a = move_form.save()
+ invoice_a.action_post()
+
+ self.assertEqual(len(self.sale_order.order_line), 1, "No SO line should have been created (or removed) when validating vendor bill")
+ self.assertTrue(invoice_a.mapped('line_ids.analytic_line_ids'), "Analytic lines should be generated")
+
+ def test_not_reinvoicing_invoiced_so_lines(self):
+ """ Test that invoiced SO lines are not re-invoiced. """
+ so_line1 = self.env['sale.order.line'].create({
+ 'name': self.company_data['product_delivery_cost'].name,
+ 'product_id': self.company_data['product_delivery_cost'].id,
+ 'product_uom_qty': 1,
+ 'product_uom': self.company_data['product_delivery_cost'].uom_id.id,
+ 'price_unit': self.company_data['product_delivery_cost'].list_price,
+ 'discount': 100.00,
+ 'order_id': self.sale_order.id,
+ })
+ so_line1.product_id_change()
+ so_line2 = self.env['sale.order.line'].create({
+ 'name': self.company_data['product_delivery_sales_price'].name,
+ 'product_id': self.company_data['product_delivery_sales_price'].id,
+ 'product_uom_qty': 1,
+ 'product_uom': self.company_data['product_delivery_sales_price'].uom_id.id,
+ 'price_unit': self.company_data['product_delivery_sales_price'].list_price,
+ 'discount': 100.00,
+ 'order_id': self.sale_order.id,
+ })
+ so_line2.product_id_change()
+
+ self.sale_order.onchange_partner_id()
+ self.sale_order._compute_tax_id()
+ self.sale_order.action_confirm()
+
+ for line in self.sale_order.order_line:
+ line.qty_delivered = 1
+ # create invoice and validate it
+ invoice = self.sale_order._create_invoices()
+ invoice.action_post()
+
+ so_line3 = self.sale_order.order_line.filtered(lambda sol: sol != so_line1 and sol.product_id == self.company_data['product_delivery_cost'])
+ so_line4 = self.sale_order.order_line.filtered(lambda sol: sol != so_line2 and sol.product_id == self.company_data['product_delivery_sales_price'])
+
+ self.assertFalse(so_line3, "No re-invoicing should have created a new sale line with product #1")
+ self.assertFalse(so_line4, "No re-invoicing should have created a new sale line with product #2")
+ self.assertEqual(so_line1.qty_delivered, 1, "No re-invoicing should have impacted exising SO line 1")
+ self.assertEqual(so_line2.qty_delivered, 1, "No re-invoicing should have impacted exising SO line 2")
diff --git a/addons/sale/tests/test_sale_flow.py b/addons/sale/tests/test_sale_flow.py
new file mode 100644
index 00000000..c18d645f
--- /dev/null
+++ b/addons/sale/tests/test_sale_flow.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+from odoo.addons.sale.tests.common import TestSaleCommonBase
+
+
+class TestSaleFlow(TestSaleCommonBase):
+ ''' Test running at-install to test flows independently to other modules, e.g. 'sale_stock'. '''
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ user = cls.env['res.users'].create({
+ 'name': 'Because I am saleman!',
+ 'login': 'saleman',
+ 'groups_id': [(6, 0, cls.env.user.groups_id.ids), (4, cls.env.ref('account.group_account_user').id)],
+ })
+ user.partner_id.email = 'saleman@test.com'
+
+ # Shadow the current environment/cursor with the newly created user.
+ cls.env = cls.env(user=user)
+ cls.cr = cls.env.cr
+
+ cls.company = cls.env['res.company'].create({
+ 'name': 'Test Company',
+ 'currency_id': cls.env.ref('base.USD').id,
+ })
+ cls.company_data = cls.setup_sale_configuration_for_company(cls.company)
+
+ cls.partner_a = cls.env['res.partner'].create({
+ 'name': 'partner_a',
+ 'company_id': False,
+ })
+
+ cls.analytic_account = cls.env['account.analytic.account'].create({
+ 'name': 'Test analytic_account',
+ 'code': 'analytic_account',
+ 'company_id': cls.company.id,
+ 'partner_id': cls.partner_a.id
+ })
+
+ user.company_ids |= cls.company
+ user.company_id = cls.company
+
+ def test_qty_delivered(self):
+ ''' Test 'qty_delivered' at-install to avoid a change in the behavior when 'sale_stock' is installed. '''
+
+ sale_order = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
+ 'partner_id': self.partner_a.id,
+ 'partner_invoice_id': self.partner_a.id,
+ 'partner_shipping_id': self.partner_a.id,
+ 'analytic_account_id': self.analytic_account.id,
+ 'pricelist_id': self.company_data['default_pricelist'].id,
+ 'order_line': [
+ (0, 0, {
+ 'name': self.company_data['product_order_cost'].name,
+ 'product_id': self.company_data['product_order_cost'].id,
+ 'product_uom_qty': 2,
+ 'qty_delivered': 1,
+ 'product_uom': self.company_data['product_order_cost'].uom_id.id,
+ 'price_unit': self.company_data['product_order_cost'].list_price,
+ }),
+ (0, 0, {
+ 'name': self.company_data['product_delivery_cost'].name,
+ 'product_id': self.company_data['product_delivery_cost'].id,
+ 'product_uom_qty': 4,
+ 'qty_delivered': 1,
+ 'product_uom': self.company_data['product_delivery_cost'].uom_id.id,
+ 'price_unit': self.company_data['product_delivery_cost'].list_price,
+ }),
+ ],
+ })
+ for line in sale_order.order_line:
+ line.product_id_change()
+
+ sale_order.onchange_partner_id()
+ sale_order._compute_tax_id()
+ sale_order.action_confirm()
+
+ self.assertRecordValues(sale_order.order_line, [
+ {'qty_delivered': 1.0},
+ {'qty_delivered': 1.0},
+ ])
diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py
new file mode 100644
index 00000000..48985150
--- /dev/null
+++ b/addons/sale/tests/test_sale_order.py
@@ -0,0 +1,584 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo.exceptions import UserError, AccessError
+from odoo.tests import Form, tagged
+from odoo.tools import float_compare
+
+from .common import TestSaleCommon
+
+
+@tagged('post_install', '-at_install')
+class TestSaleOrder(TestSaleCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ SaleOrder = cls.env['sale.order'].with_context(tracking_disable=True)
+
+ # set up users
+ cls.crm_team0 = cls.env['crm.team'].create({
+ 'name': 'crm team 0',
+ 'company_id': cls.company_data['company'].id
+ })
+ cls.crm_team1 = cls.env['crm.team'].create({
+ 'name': 'crm team 1',
+ 'company_id': cls.company_data['company'].id
+ })
+ cls.user_in_team = cls.env['res.users'].create({
+ 'email': 'team0user@example.com',
+ 'login': 'team0user',
+ 'name': 'User in Team 0',
+ 'sale_team_id': cls.crm_team0.id
+ })
+ cls.user_not_in_team = cls.env['res.users'].create({
+ 'email': 'noteamuser@example.com',
+ 'login': 'noteamuser',
+ 'name': 'User Not In Team',
+ })
+
+ # create a generic Sale Order with all classical products and empty pricelist
+ cls.sale_order = SaleOrder.create({
+ 'partner_id': cls.partner_a.id,
+ 'partner_invoice_id': cls.partner_a.id,
+ 'partner_shipping_id': cls.partner_a.id,
+ 'pricelist_id': cls.company_data['default_pricelist'].id,
+ })
+ cls.sol_product_order = cls.env['sale.order.line'].create({
+ 'name': cls.company_data['product_order_no'].name,
+ 'product_id': cls.company_data['product_order_no'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_order_no'].uom_id.id,
+ 'price_unit': cls.company_data['product_order_no'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_serv_deliver = cls.env['sale.order.line'].create({
+ 'name': cls.company_data['product_service_delivery'].name,
+ 'product_id': cls.company_data['product_service_delivery'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_service_delivery'].uom_id.id,
+ 'price_unit': cls.company_data['product_service_delivery'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_serv_order = cls.env['sale.order.line'].create({
+ 'name': cls.company_data['product_service_order'].name,
+ 'product_id': cls.company_data['product_service_order'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_service_order'].uom_id.id,
+ 'price_unit': cls.company_data['product_service_order'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_product_deliver = cls.env['sale.order.line'].create({
+ 'name': cls.company_data['product_delivery_no'].name,
+ 'product_id': cls.company_data['product_delivery_no'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_delivery_no'].uom_id.id,
+ 'price_unit': cls.company_data['product_delivery_no'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+
+ def test_sale_order(self):
+ """ Test the sales order flow (invoicing and quantity updates)
+ - Invoice repeatedly while varrying delivered quantities and check that invoice are always what we expect
+ """
+ # TODO?: validate invoice and register payments
+ self.sale_order.order_line.read(['name', 'price_unit', 'product_uom_qty', 'price_total'])
+
+ self.assertEqual(self.sale_order.amount_total, 1240.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_template(email_ctx.get('default_template_id'))
+ 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')
+
+ # create invoice: only 'invoice on order' products are invoiced
+ invoice = self.sale_order._create_invoices()
+ self.assertEqual(len(invoice.invoice_line_ids), 2, 'Sale: invoice is missing lines')
+ self.assertEqual(invoice.amount_total, 740.0, 'Sale: invoice total amount is wrong')
+ self.assertTrue(self.sale_order.invoice_status == 'no', 'Sale: SO status after invoicing should be "nothing to invoice"')
+ self.assertTrue(len(self.sale_order.invoice_ids) == 1, 'Sale: invoice is missing')
+ self.sale_order.order_line._compute_product_updatable()
+ self.assertFalse(self.sale_order.order_line[0].product_updatable)
+
+ # deliver lines except 'time and material' then invoice again
+ for line in self.sale_order.order_line:
+ line.qty_delivered = 2 if line.product_id.expense_policy == 'no' else 0
+ self.assertTrue(self.sale_order.invoice_status == 'to invoice', 'Sale: SO status after delivery should be "to invoice"')
+ invoice2 = self.sale_order._create_invoices()
+ self.assertEqual(len(invoice2.invoice_line_ids), 2, 'Sale: second invoice is missing lines')
+ self.assertEqual(invoice2.amount_total, 500.0, 'Sale: second invoice total amount is wrong')
+ self.assertTrue(self.sale_order.invoice_status == 'invoiced', 'Sale: SO status after invoicing everything should be "invoiced"')
+ self.assertTrue(len(self.sale_order.invoice_ids) == 2, 'Sale: invoice is missing')
+
+ # go over the sold quantity
+ self.sol_serv_order.write({'qty_delivered': 10})
+ self.assertTrue(self.sale_order.invoice_status == 'upselling', 'Sale: SO status after increasing delivered qty higher than ordered qty should be "upselling"')
+
+ # upsell and invoice
+ self.sol_serv_order.write({'product_uom_qty': 10})
+ # There is a bug with `new` and `_origin`
+ # If you create a first new from a record, then change a value on the origin record, than create another new,
+ # this other new wont have the updated value of the origin record, but the one from the previous new
+ # Here the problem lies in the use of `new` in `move = self_ctx.new(new_vals)`,
+ # and the fact this method is called multiple times in the same transaction test case.
+ # Here, we update `qty_delivered` on the origin record, but the `new` records which are in cache with this order line
+ # as origin are not updated, nor the fields that depends on it.
+ self.sol_serv_order.flush()
+ for field in self.env['sale.order.line']._fields.values():
+ for res_id in list(self.env.cache._data[field]):
+ if not res_id:
+ self.env.cache._data[field].pop(res_id)
+
+ invoice3 = self.sale_order._create_invoices()
+ self.assertEqual(len(invoice3.invoice_line_ids), 1, 'Sale: third invoice is missing lines')
+ self.assertEqual(invoice3.amount_total, 720.0, 'Sale: second invoice total amount is wrong')
+ self.assertTrue(self.sale_order.invoice_status == 'invoiced', 'Sale: SO status after invoicing everything (including the upsel) should be "invoiced"')
+
+ 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.company_data['default_user_salesman']).create({
+ 'partner_id': self.company_data['default_user_salesman'].partner_id.id,
+ 'order_line': [[0, 0, {
+ 'name': self.company_data['product_order_no'].name,
+ 'product_id': self.company_data['product_order_no'].id,
+ 'product_uom_qty': 1,
+ 'price_unit': self.company_data['product_order_no'].list_price,
+ }]]
+ })
+ 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.company_data['default_user_salesman']).message_post_with_template(mail_template.id)
+ 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.company_data['default_user_employee']).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.company_data['default_user_employee']).unlink()
+ self.assertTrue(so_copy.unlink(), 'Sale: deleting a cancelled SO should be possible')
+
+ # SO in state 'sale' or 'done' 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_done()
+ self.assertTrue(self.sale_order.state == 'done', 'Sale: SO should be in state "done"')
+ with self.assertRaises(UserError):
+ self.sale_order.unlink()
+
+ def test_cost_invoicing(self):
+ """ Test confirming a vendor invoice to reinvoice cost on the so """
+ serv_cost = self.env['product.product'].create({
+ 'name': "Ordered at cost",
+ 'standard_price': 160,
+ 'list_price': 180,
+ 'type': 'consu',
+ 'invoice_policy': 'order',
+ 'expense_policy': 'cost',
+ 'default_code': 'PROD_COST',
+ 'service_type': 'manual',
+ })
+ prod_gap = self.company_data['product_service_order']
+ so = self.env['sale.order'].create({
+ 'partner_id': self.partner_a.id,
+ 'partner_invoice_id': self.partner_a.id,
+ 'partner_shipping_id': self.partner_a.id,
+ 'order_line': [(0, 0, {'name': prod_gap.name, 'product_id': prod_gap.id, 'product_uom_qty': 2, 'product_uom': prod_gap.uom_id.id, 'price_unit': prod_gap.list_price})],
+ 'pricelist_id': self.company_data['default_pricelist'].id,
+ })
+ so.action_confirm()
+ so._create_analytic_account()
+
+ inv = self.env['account.move'].with_context(default_move_type='in_invoice').create({
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': so.date_order,
+ 'invoice_line_ids': [
+ (0, 0, {
+ 'name': serv_cost.name,
+ 'product_id': serv_cost.id,
+ 'product_uom_id': serv_cost.uom_id.id,
+ 'quantity': 2,
+ 'price_unit': serv_cost.standard_price,
+ 'analytic_account_id': so.analytic_account_id.id,
+ }),
+ ],
+ })
+ inv.action_post()
+ sol = so.order_line.filtered(lambda l: l.product_id == serv_cost)
+ self.assertTrue(sol, 'Sale: cost invoicing does not add lines when confirming vendor invoice')
+ self.assertEqual((sol.price_unit, sol.qty_delivered, sol.product_uom_qty, sol.qty_invoiced), (160, 2, 0, 0), 'Sale: line is wrong after confirming vendor invoice')
+
+ def test_sale_with_taxes(self):
+ """ Test SO with taxes applied on its lines and check subtotal applied on its lines and total applied on the SO """
+ # Create a tax with price included
+ tax_include = self.env['account.tax'].create({
+ 'name': 'Tax with price include',
+ 'amount': 10,
+ 'price_include': True
+ })
+ # Create a tax with price not included
+ tax_exclude = self.env['account.tax'].create({
+ 'name': 'Tax with no price include',
+ 'amount': 10,
+ })
+
+ # Apply taxes on the sale order lines
+ self.sol_product_order.write({'tax_id': [(4, tax_include.id)]})
+ self.sol_serv_deliver.write({'tax_id': [(4, tax_include.id)]})
+ self.sol_serv_order.write({'tax_id': [(4, tax_exclude.id)]})
+ self.sol_product_deliver.write({'tax_id': [(4, tax_exclude.id)]})
+
+ # Trigger onchange to reset discount, unit price, subtotal, ...
+ for line in self.sale_order.order_line:
+ line.product_id_change()
+ line._onchange_discount()
+
+ for line in self.sale_order.order_line:
+ if line.tax_id.price_include:
+ price = line.price_unit * line.product_uom_qty - line.price_tax
+ else:
+ price = line.price_unit * line.product_uom_qty
+
+ self.assertEqual(float_compare(line.price_subtotal, price, precision_digits=2), 0)
+
+ self.assertEqual(self.sale_order.amount_total,
+ self.sale_order.amount_untaxed + self.sale_order.amount_tax,
+ 'Taxes should be applied')
+
+ def test_so_create_multicompany(self):
+ """Check that only taxes of the right company are applied on the lines."""
+
+ # Preparing test Data
+ product_shared = self.env['product.template'].create({
+ 'name': 'shared product',
+ 'invoice_policy': 'order',
+ 'taxes_id': [(6, False, (self.company_data['default_tax_sale'] + self.company_data_2['default_tax_sale']).ids)],
+ 'property_account_income_id': self.company_data['default_account_revenue'].id,
+ })
+
+ so_1 = self.env['sale.order'].with_user(self.company_data['default_user_salesman']).create({
+ 'partner_id': self.env['res.partner'].create({'name': 'A partner'}).id,
+ 'company_id': self.company_data['company'].id,
+ })
+ so_1.write({
+ 'order_line': [(0, False, {'product_id': product_shared.product_variant_id.id, 'order_id': so_1.id})],
+ })
+
+ self.assertEqual(so_1.order_line.tax_id, self.company_data['default_tax_sale'],
+ 'Only taxes from the right company are put by default')
+ so_1.action_confirm()
+ # i'm not interested in groups/acls, but in the multi-company flow only
+ # the sudo is there for that and does not impact the invoice that gets created
+ # the goal here is to invoice in company 1 (because the order is in company 1) while being
+ # 'mainly' in company 2 (through the context), the invoice should be in company 1
+ inv=so_1.sudo()\
+ .with_context(allowed_company_ids=(self.company_data['company'] + self.company_data_2['company']).ids)\
+ ._create_invoices()
+ self.assertEqual(inv.company_id, self.company_data['company'], 'invoices should be created in the company of the SO, not the main company of the context')
+
+ def test_group_invoice(self):
+ """ Test that invoicing multiple sales order for the same customer works. """
+ # Create 3 SOs for the same partner, one of which that uses another currency
+ eur_pricelist = self.env['product.pricelist'].create({'name': 'EUR', 'currency_id': self.env.ref('base.EUR').id})
+ so1 = self.sale_order.with_context(mail_notrack=True).copy()
+ so1.pricelist_id = eur_pricelist
+ so2 = so1.copy()
+ usd_pricelist = self.env['product.pricelist'].create({'name': 'USD', 'currency_id': self.env.ref('base.USD').id})
+ so3 = so1.copy()
+ so1.pricelist_id = usd_pricelist
+ orders = so1 | so2 | so3
+ orders.action_confirm()
+ # Create the invoicing wizard and invoice all of them at once
+ wiz = self.env['sale.advance.payment.inv'].with_context(active_ids=orders.ids, open_invoices=True).create({})
+ res = wiz.create_invoices()
+ # Check that exactly 2 invoices are generated
+ self.assertEqual(len(res['domain'][0][2]),2, "Grouping invoicing 3 orders for the same partner with 2 currencies should create exactly 2 invoices")
+
+ def test_so_note_to_invoice(self):
+ """Test that notes from SO are pushed into invoices"""
+
+ sol_note = self.env['sale.order.line'].create({
+ 'name': 'This is a note',
+ 'display_type': 'line_note',
+ 'product_id': False,
+ 'product_uom_qty': 0,
+ 'product_uom': False,
+ 'price_unit': 0,
+ 'order_id': self.sale_order.id,
+ 'tax_id': False,
+ })
+
+ # confirm quotation
+ self.sale_order.action_confirm()
+
+ # create invoice
+ invoice = self.sale_order._create_invoices()
+
+ # check note from SO has been pushed in invoice
+ self.assertEqual(len(invoice.invoice_line_ids.filtered(lambda line: line.display_type == 'line_note')), 1, 'Note SO line should have been pushed to the invoice')
+
+ def test_multi_currency_discount(self):
+ """Verify the currency used for pricelist price & discount computation."""
+ products = self.env["product.product"].search([], limit=2)
+ product_1 = products[0]
+ product_2 = products[1]
+
+ # Make sure the company is in USD
+ main_company = self.env.ref('base.main_company')
+ main_curr = main_company.currency_id
+ current_curr = self.env.company.currency_id
+ other_curr = self.currency_data['currency']
+ # main_company.currency_id = other_curr # product.currency_id when no company_id set
+ other_company = self.env["res.company"].create({
+ "name": "Test",
+ "currency_id": other_curr.id
+ })
+ user_in_other_company = self.env["res.users"].create({
+ "company_id": other_company.id,
+ "company_ids": [(6, 0, [other_company.id])],
+ "name": "E.T",
+ "login": "hohoho",
+ })
+ user_in_other_company.groups_id |= self.env.ref('product.group_discount_per_so_line')
+ self.env['res.currency.rate'].search([]).unlink()
+ self.env['res.currency.rate'].create({
+ 'name': '2010-01-01',
+ 'rate': 2.0,
+ 'currency_id': main_curr.id,
+ "company_id": False,
+ })
+
+ product_1.company_id = False
+ product_2.company_id = False
+
+ self.assertEqual(product_1.currency_id, main_curr)
+ self.assertEqual(product_2.currency_id, main_curr)
+ self.assertEqual(product_1.cost_currency_id, current_curr)
+ self.assertEqual(product_2.cost_currency_id, current_curr)
+
+ product_1_ctxt = product_1.with_user(user_in_other_company)
+ product_2_ctxt = product_2.with_user(user_in_other_company)
+ self.assertEqual(product_1_ctxt.currency_id, main_curr)
+ self.assertEqual(product_2_ctxt.currency_id, main_curr)
+ self.assertEqual(product_1_ctxt.cost_currency_id, other_curr)
+ self.assertEqual(product_2_ctxt.cost_currency_id, other_curr)
+
+ product_1.lst_price = 100.0
+ product_2_ctxt.standard_price = 10.0 # cost is company_dependent
+
+ pricelist = self.env["product.pricelist"].create({
+ "name": "Test multi-currency",
+ "discount_policy": "without_discount",
+ "currency_id": other_curr.id,
+ "item_ids": [
+ (0, 0, {
+ "base": "list_price",
+ "product_id": product_1.id,
+ "compute_price": "percentage",
+ "percent_price": 20,
+ }),
+ (0, 0, {
+ "base": "standard_price",
+ "product_id": product_2.id,
+ "compute_price": "percentage",
+ "percent_price": 10,
+ })
+ ]
+ })
+
+ # Create a SO in the other company
+ ##################################
+ # product_currency = main_company.currency_id when no company_id on the product
+
+ # CASE 1:
+ # company currency = so currency
+ # product_1.currency != so currency
+ # product_2.cost_currency_id = so currency
+ sales_order = product_1_ctxt.with_context(mail_notrack=True, mail_create_nolog=True).env["sale.order"].create({
+ "partner_id": self.env.user.partner_id.id,
+ "pricelist_id": pricelist.id,
+ "order_line": [
+ (0, 0, {
+ "product_id": product_1.id,
+ "product_uom_qty": 1.0
+ }),
+ (0, 0, {
+ "product_id": product_2.id,
+ "product_uom_qty": 1.0
+ })
+ ]
+ })
+ for line in sales_order.order_line:
+ # Create values autofill does not compute discount.
+ line._onchange_discount()
+
+ so_line_1 = sales_order.order_line[0]
+ so_line_2 = sales_order.order_line[1]
+ self.assertEqual(so_line_1.discount, 20)
+ self.assertEqual(so_line_1.price_unit, 50.0)
+ self.assertEqual(so_line_2.discount, 10)
+ self.assertEqual(so_line_2.price_unit, 10)
+
+ # CASE 2
+ # company currency != so currency
+ # product_1.currency == so currency
+ # product_2.cost_currency_id != so currency
+ pricelist.currency_id = main_curr
+ sales_order = product_1_ctxt.with_context(mail_notrack=True, mail_create_nolog=True).env["sale.order"].create({
+ "partner_id": self.env.user.partner_id.id,
+ "pricelist_id": pricelist.id,
+ "order_line": [
+ # Verify discount is considered in create hack
+ (0, 0, {
+ "product_id": product_1.id,
+ "product_uom_qty": 1.0
+ }),
+ (0, 0, {
+ "product_id": product_2.id,
+ "product_uom_qty": 1.0
+ })
+ ]
+ })
+ for line in sales_order.order_line:
+ line._onchange_discount()
+
+ so_line_1 = sales_order.order_line[0]
+ so_line_2 = sales_order.order_line[1]
+ self.assertEqual(so_line_1.discount, 20)
+ self.assertEqual(so_line_1.price_unit, 100.0)
+ self.assertEqual(so_line_2.discount, 10)
+ self.assertEqual(so_line_2.price_unit, 20)
+
+ 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,
+ 'team_id': self.crm_team1.id,
+ })
+ sale_order = self.env['sale.order'].create({
+ 'partner_id': partner.id,
+ })
+ sale_order.onchange_partner_id()
+ self.assertEqual(sale_order.team_id.id, self.crm_team0.id, 'Should assign to team of sales person')
+
+ def test_assign_sales_team_from_partner_team(self):
+ """If no team set on the customer's sales person, fall back to the customer's team"""
+ partner = self.env['res.partner'].create({
+ 'name': 'Customer of User Not In Team',
+ 'user_id': self.user_not_in_team.id,
+ 'team_id': self.crm_team1.id,
+ })
+ sale_order = self.env['sale.order'].create({
+ 'partner_id': partner.id,
+ })
+ sale_order.onchange_partner_id()
+ self.assertEqual(sale_order.team_id.id, self.crm_team1.id, 'Should assign to team of partner')
+
+ 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_a.id,
+ 'team_id': self.crm_team1.id
+ })
+ sale_order.user_id = self.user_in_team
+ sale_order.onchange_user_id()
+ self.assertEqual(sale_order.team_id.id, self.crm_team0.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_a.id,
+ 'team_id': self.crm_team1.id
+ })
+ sale_order.user_id = self.user_not_in_team
+ sale_order.onchange_user_id()
+ self.assertEqual(sale_order.team_id.id, self.crm_team1.id, 'Should not reset the team to default')
+
+ def test_discount_and_untaxed_subtotal(self):
+ """When adding a discount on a SO line, this test ensures that the untaxed amount to invoice is
+ equal to the untaxed subtotal"""
+ sale_order = self.env['sale.order'].create({
+ 'partner_id': self.partner_a.id,
+ 'order_line': [(0, 0, {
+ 'product_id': self.product_a.id,
+ 'product_uom_qty': 38,
+ 'price_unit': 541.26,
+ 'discount': 2.00,
+ })]
+ })
+ sale_order.action_confirm()
+ line = sale_order.order_line
+ self.assertEqual(line.untaxed_amount_to_invoice, 0)
+
+ line.qty_delivered = 38
+ # (541.26 - 0.02 * 541.26) * 38 = 20156.5224 ~= 20156.52
+ self.assertEqual(line.price_subtotal, 20156.52)
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_subtotal)
+
+ # Same with an included-in-price tax
+ sale_order = sale_order.copy()
+ line = sale_order.order_line
+ line.tax_id = [(0, 0, {
+ 'name': 'Super Tax',
+ 'amount_type': 'percent',
+ 'amount': 15.0,
+ 'price_include': True,
+ })]
+ sale_order.action_confirm()
+ self.assertEqual(line.untaxed_amount_to_invoice, 0)
+
+ line.qty_delivered = 38
+ # (541,26 / 1,15) * ,98 * 38 = 17527,410782609 ~= 17527.41
+ self.assertEqual(line.price_subtotal, 17527.41)
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_subtotal)
diff --git a/addons/sale/tests/test_sale_pricelist.py b/addons/sale/tests/test_sale_pricelist.py
new file mode 100644
index 00000000..7c0fd5c5
--- /dev/null
+++ b/addons/sale/tests/test_sale_pricelist.py
@@ -0,0 +1,163 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from .common import TestSaleCommon
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestSaleOrder(TestSaleCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ Pricelist = cls.env['product.pricelist']
+ PricelistItem = cls.env['product.pricelist.item']
+ SaleOrder = cls.env['sale.order'].with_context(tracking_disable=True)
+ SaleOrderLine = cls.env['sale.order.line'].with_context(tracking_disable=True)
+
+ # Create a product category
+ cls.product_category_1 = cls.env['product.category'].create({
+ 'name': 'Product Category for pricelist',
+ })
+ # Create a pricelist with discount policy: percentage on services, fixed price for product_order
+ cls.pricelist_discount_incl = Pricelist.create({
+ 'name': 'Pricelist A',
+ 'discount_policy': 'with_discount',
+ 'company_id': cls.company_data['company'].id,
+ })
+ PricelistItem.create({
+ 'pricelist_id': cls.pricelist_discount_incl.id,
+ 'applied_on': '1_product',
+ 'product_tmpl_id': cls.company_data['product_service_order'].product_tmpl_id.id,
+ 'compute_price': 'percentage',
+ 'percent_price': 10
+ })
+ PricelistItem.create({
+ 'pricelist_id': cls.pricelist_discount_incl.id,
+ 'applied_on': '1_product',
+ 'product_tmpl_id': cls.company_data['product_service_delivery'].product_tmpl_id.id,
+ 'compute_price': 'percentage',
+ 'percent_price': 20,
+ })
+ cls.pricelist_discount_incl_item3 = PricelistItem.create({
+ 'pricelist_id': cls.pricelist_discount_incl.id,
+ 'applied_on': '1_product',
+ 'product_tmpl_id': cls.company_data['product_order_no'].product_tmpl_id.id,
+ 'compute_price': 'fixed',
+ 'fixed_price': 211,
+ })
+
+ # Create a pricelist without discount policy: formula for product_category_1 category, percentage for service_order
+ cls.pricelist_discount_excl = Pricelist.create({
+ 'name': 'Pricelist B',
+ 'discount_policy': 'without_discount',
+ 'company_id': cls.company_data['company'].id,
+ })
+ PricelistItem.create({
+ 'pricelist_id': cls.pricelist_discount_excl.id,
+ 'applied_on': '2_product_category',
+ 'categ_id': cls.product_category_1.id,
+ 'compute_price': 'formula',
+ 'base': 'standard_price',
+ 'price_discount': 10,
+ })
+ PricelistItem.create({
+ 'pricelist_id': cls.pricelist_discount_excl.id,
+ 'applied_on': '1_product',
+ 'product_tmpl_id': cls.company_data['product_service_order'].product_tmpl_id.id,
+ 'compute_price': 'percentage',
+ 'percent_price': 20,
+ })
+
+ # create a generic Sale Order with all classical products and empty pricelist
+ cls.sale_order = SaleOrder.create({
+ 'partner_id': cls.partner_a.id,
+ 'partner_invoice_id': cls.partner_a.id,
+ 'partner_shipping_id': cls.partner_a.id,
+ 'pricelist_id': cls.company_data['default_pricelist'].id,
+ })
+ cls.sol_product_order = SaleOrderLine.create({
+ 'name': cls.company_data['product_order_no'].name,
+ 'product_id': cls.company_data['product_order_no'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_order_no'].uom_id.id,
+ 'price_unit': cls.company_data['product_order_no'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_serv_deliver = SaleOrderLine.create({
+ 'name': cls.company_data['product_service_delivery'].name,
+ 'product_id': cls.company_data['product_service_delivery'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_service_delivery'].uom_id.id,
+ 'price_unit': cls.company_data['product_service_delivery'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_serv_order = SaleOrderLine.create({
+ 'name': cls.company_data['product_service_order'].name,
+ 'product_id': cls.company_data['product_service_order'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_service_order'].uom_id.id,
+ 'price_unit': cls.company_data['product_service_order'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_prod_deliver = SaleOrderLine.create({
+ 'name': cls.company_data['product_delivery_no'].name,
+ 'product_id': cls.company_data['product_delivery_no'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_delivery_no'].uom_id.id,
+ 'price_unit': cls.company_data['product_delivery_no'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+
+ def test_sale_with_pricelist_discount_included(self):
+ """ Test SO with the pricelist and check unit price appeared on its lines """
+ # Change the pricelist
+ self.sale_order.write({'pricelist_id': self.pricelist_discount_incl.id})
+ # Trigger onchange to reset discount, unit price, subtotal, ...
+ for line in self.sale_order.order_line:
+ line.product_id_change()
+ line._onchange_discount()
+ # Check that pricelist of the SO has been applied on the sale order lines or not
+ for line in self.sale_order.order_line:
+ if line.product_id == self.company_data['product_order_no']:
+ self.assertEqual(line.price_unit, self.pricelist_discount_incl_item3.fixed_price, 'Price of product_order should be %s applied on the order line' % (self.pricelist_discount_incl_item3.fixed_price,))
+ else: # only services (service_order and service_deliver)
+ for item in self.sale_order.pricelist_id.item_ids.filtered(lambda l: l.product_tmpl_id == line.product_id.product_tmpl_id):
+ price = item.percent_price
+ self.assertEqual(price, (line.product_id.list_price - line.price_unit) / line.product_id.list_price * 100, 'Pricelist of the SO should be applied on an order line %s' % (line.product_id.name,))
+
+ def test_sale_with_pricelist_discount_excluded(self):
+ """ Test SO with the pricelist 'discount displayed' and check discount and unit price appeared on its lines """
+ # Add group 'Discount on Lines' to the user
+ self.env.user.write({'groups_id': [(4, self.env.ref('product.group_discount_per_so_line').id)]})
+
+ # Set product category on consumable products (for the pricelist item applying on this category)
+ self.company_data['product_order_no'].write({'categ_id': self.product_category_1.id})
+ self.company_data['product_delivery_no'].write({'categ_id': self.product_category_1.id})
+
+ # Change the pricelist
+ self.sale_order.write({'pricelist_id': self.pricelist_discount_excl.id})
+ # Trigger onchange to reset discount, unit price, subtotal, ...
+ for line in self.sale_order.order_line:
+ line.product_id_change()
+ line._onchange_discount()
+
+ # Check pricelist of the SO apply or not on order lines where pricelist contains formula that add 15% on the cost price
+ for line in self.sale_order.order_line:
+ if line.product_id.categ_id in self.sale_order.pricelist_id.item_ids.mapped('categ_id'): # reduction per category (consummable only)
+ for item in self.sale_order.pricelist_id.item_ids.filtered(lambda l: l.categ_id == line.product_id.categ_id):
+ self.assertEqual(line.discount, item.price_discount, "Discount should be displayed on order line %s since its category get some discount" % (line.name,))
+ self.assertEqual(line.price_unit, line.product_id.standard_price, "Price unit should be the cost price for product %s" % (line.name,))
+ else:
+ if line.product_id == self.company_data['product_service_order']: # reduction for this product
+ self.assertEqual(line.discount, 20.0, "Discount for product %s should be 20 percent with pricelist %s" % (line.name, self.pricelist_discount_excl.name))
+ self.assertEqual(line.price_unit, line.product_id.list_price, 'Unit price of order line should be a sale price as the pricelist not applied on the other category\'s product')
+ else: # no discount for the rest
+ self.assertEqual(line.discount, 0.0, 'Pricelist of SO should not be applied on an order line')
+ self.assertEqual(line.price_unit, line.product_id.list_price, 'Unit price of order line should be a sale price as the pricelist not applied on the other category\'s product')
diff --git a/addons/sale/tests/test_sale_product_attribute_value_config.py b/addons/sale/tests/test_sale_product_attribute_value_config.py
new file mode 100644
index 00000000..68d48d7f
--- /dev/null
+++ b/addons/sale/tests/test_sale_product_attribute_value_config.py
@@ -0,0 +1,400 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields
+from odoo.addons.product.tests.test_product_attribute_value_config import TestProductAttributeValueCommon
+from odoo.tests import tagged
+
+
+class TestSaleProductAttributeValueCommon(TestProductAttributeValueCommon):
+
+ @classmethod
+ def _setup_currency(cls, currency_ratio=2):
+ """Get or create a currency. This makes the test non-reliant on demo.
+
+ With an easy currency rate, for a simple 2 ratio in the following tests.
+ """
+ from_currency = cls.computer.currency_id
+ cls._set_or_create_rate_today(from_currency, rate=1)
+
+ to_currency = cls._get_or_create_currency("my currency", "C")
+ cls._set_or_create_rate_today(to_currency, currency_ratio)
+ return to_currency
+
+ @classmethod
+ def _set_or_create_rate_today(cls, currency, rate):
+ """Get or create a currency rate for today. This makes the test
+ non-reliant on demo data."""
+ name = fields.Date.today()
+ currency_id = currency.id
+ company_id = cls.env.company.id
+
+ CurrencyRate = cls.env['res.currency.rate']
+
+ currency_rate = CurrencyRate.search([
+ ('company_id', '=', company_id),
+ ('currency_id', '=', currency_id),
+ ('name', '=', name),
+ ])
+
+ if currency_rate:
+ currency_rate.rate = rate
+ else:
+ CurrencyRate.create({
+ 'company_id': company_id,
+ 'currency_id': currency_id,
+ 'name': name,
+ 'rate': rate,
+ })
+
+ @classmethod
+ def _get_or_create_currency(cls, name, symbol):
+ """Get or create a currency based on name. This makes the test
+ non-reliant on demo data."""
+ currency = cls.env['res.currency'].search([('name', '=', name)])
+ return currency or currency.create({
+ 'name': name,
+ 'symbol': symbol,
+ })
+
+
+@tagged('post_install', '-at_install')
+class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueCommon):
+ def _setup_pricelist(self, currency_ratio=2):
+ to_currency = self._setup_currency(currency_ratio)
+
+ discount = 10
+
+ pricelist = self.env['product.pricelist'].create({
+ 'name': 'test pl',
+ 'currency_id': to_currency.id,
+ 'company_id': self.computer.company_id.id,
+ })
+
+ pricelist_item = self.env['product.pricelist.item'].create({
+ 'min_quantity': 2,
+ 'compute_price': 'percentage',
+ 'percent_price': discount,
+ 'pricelist_id': pricelist.id,
+ })
+
+ return (pricelist, pricelist_item, currency_ratio, 1 - discount / 100)
+
+ def test_01_is_combination_possible_archived(self):
+ """The goal is to test the possibility of archived combinations.
+
+ This test could not be put into product module because there was no
+ model which had product_id as required and without cascade on delete.
+ Here we have the sales order line in this situation.
+
+ This is a necessary condition for `_create_variant_ids` to archive
+ instead of delete the variants.
+ """
+ def do_test(self):
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
+
+ variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ variant2 = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_2)
+
+ self.assertTrue(variant)
+ self.assertTrue(variant2)
+
+ # Create a dummy SO to prevent the variant from being deleted by
+ # _create_variant_ids() because the variant is a related field that
+ # is required on the SO line
+ so = self.env['sale.order'].create({'partner_id': 1})
+ self.env['sale.order.line'].create({
+ 'order_id': so.id,
+ 'name': "test",
+ 'product_id': variant.id
+ })
+ # additional variant to test correct ignoring when mismatch values
+ self.env['sale.order.line'].create({
+ 'order_id': so.id,
+ 'name': "test",
+ 'product_id': variant2.id
+ })
+
+ variant2.active = False
+ # CASE: 1 not archived, 2 archived
+ self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
+ self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
+ # CASE: both archived combination (without no_variant)
+ variant.active = False
+ self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
+ self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
+
+ # CASE: OK after attribute line removed
+ self.computer_hdd_attribute_lines.write({'active': False})
+ self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8))
+
+ # CASE: not archived (with no_variant)
+ self.hdd_attribute.create_variant = 'no_variant'
+ self._add_hdd_attribute_line()
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
+
+ self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
+
+ # CASE: archived combination found (with no_variant)
+ variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ variant.active = False
+ self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
+
+ # CASE: archived combination has different attributes (including no_variant)
+ self.computer_ssd_attribute_lines.write({'active': False})
+
+ variant4 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
+ self.env['sale.order.line'].create({
+ 'order_id': so.id,
+ 'name': "test",
+ 'product_id': variant4.id
+ })
+ self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
+
+ # CASE: archived combination has different attributes (without no_variant)
+ self.computer_hdd_attribute_lines.write({'active': False})
+ self.hdd_attribute.create_variant = 'always'
+ self._add_hdd_attribute_line()
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
+
+ variant5 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
+ self.env['sale.order.line'].create({
+ 'order_id': so.id,
+ 'name': "test",
+ 'product_id': variant5.id
+ })
+
+ self.assertTrue(variant4 != variant5)
+
+ self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
+
+ computer_ssd_256_before = self._get_product_template_attribute_value(self.ssd_256)
+
+ do_test(self)
+
+ # CASE: add back the removed attribute and try everything again
+ self.computer_ssd_attribute_lines = self.env['product.template.attribute.line'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'attribute_id': self.ssd_attribute.id,
+ 'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])],
+ })
+
+ computer_ssd_256_after = self._get_product_template_attribute_value(self.ssd_256)
+ self.assertEqual(computer_ssd_256_after, computer_ssd_256_before)
+ self.assertEqual(computer_ssd_256_after.attribute_line_id, computer_ssd_256_before.attribute_line_id)
+ do_test(self)
+
+ def test_02_get_combination_info(self):
+ # If using multi-company, company_id will be False, and this code should
+ # still work.
+ # The case with a company_id will be implicitly tested on website_sale.
+ self.computer.company_id = False
+
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+
+ # CASE: no pricelist, no currency, with existing combination, with price_extra on attributes
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+ computer_variant = self.computer._get_variant_for_combination(combination)
+
+ res = self.computer._get_combination_info(combination)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant.id)
+ self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
+ self.assertEqual(res['price'], 2222)
+ self.assertEqual(res['list_price'], 2222)
+
+ # CASE: no combination, product given
+ res = self.computer._get_combination_info(self.env['product.template.attribute.value'], computer_variant.id)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant.id)
+ self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
+ self.assertEqual(res['price'], 2222)
+ self.assertEqual(res['list_price'], 2222)
+
+ # CASE: using pricelist, quantity rule
+ pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist()
+
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant.id)
+ self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+
+ # CASE: no_variant combination, it's another variant now
+
+ self.computer_ssd_attribute_lines.write({'active': False})
+ self.ssd_attribute.create_variant = 'no_variant'
+ self._add_ssd_attribute_line()
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+
+ computer_variant_new = self.computer._get_variant_for_combination(combination)
+ self.assertTrue(computer_variant_new)
+
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant_new.id)
+ self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)")
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+
+ # CASE: dynamic combination, but the variant already exists
+ self.computer_hdd_attribute_lines.write({'active': False})
+ self.hdd_attribute.create_variant = 'dynamic'
+ self._add_hdd_attribute_line()
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+
+ computer_variant_new = self.computer._create_product_variant(combination)
+ self.assertTrue(computer_variant_new)
+
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant_new.id)
+ self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)")
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+
+ # CASE: dynamic combination, no variant existing
+ # Test invalidate_cache on product.template _create_variant_ids
+ self._add_keyboard_attribute()
+ combination += self._get_product_template_attribute_value(self.keyboard_excluded)
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], False)
+ self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)")
+ self.assertEqual(res['price'], (2222 - 5) * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
+
+ # CASE: pricelist set value to 0, no variant
+ # Test invalidate_cache on product.pricelist write
+ pricelist_item.percent_price = 100
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], False)
+ self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)")
+ self.assertEqual(res['price'], 0)
+ self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
+
+ def test_03_get_combination_info_discount_policy(self):
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+
+ pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist()
+
+ pricelist.discount_policy = 'with_discount'
+
+ # CASE: no discount, setting with_discount
+ res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist)
+ self.assertEqual(res['price'], 2222 * currency_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+ self.assertEqual(res['has_discounted_price'], False)
+
+ # CASE: discount, setting with_discount
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+ self.assertEqual(res['has_discounted_price'], False)
+
+ # CASE: no discount, setting without_discount
+ pricelist.discount_policy = 'without_discount'
+ res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist)
+ self.assertEqual(res['price'], 2222 * currency_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+ self.assertEqual(res['has_discounted_price'], False)
+
+ # CASE: discount, setting without_discount
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+ self.assertEqual(res['has_discounted_price'], True)
+
+ def test_04_create_product_variant_non_dynamic(self):
+ """The goal of this test is to make sure the create_product_variant does
+ not create variant if the type is not dynamic. It can however return a
+ variant if it already exists."""
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ self._add_exclude(computer_ram_16, computer_hdd_1)
+
+ # CASE: variant is already created, it should return it
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+ variant1 = self.computer._get_variant_for_combination(combination)
+ self.assertEqual(self.computer._create_product_variant(combination), variant1)
+
+ # CASE: variant does not exist, but template is non-dynamic, so it
+ # should not create it
+ Product = self.env['product.product']
+ variant1.unlink()
+ self.assertEqual(self.computer._create_product_variant(combination), Product)
+
+ def test_05_create_product_variant_dynamic(self):
+ """The goal of this test is to make sure the create_product_variant does
+ work with dynamic. If the combination is possible, it should create it.
+ If it's not possible, it should not create it."""
+ self.computer_hdd_attribute_lines.write({'active': False})
+ self.hdd_attribute.create_variant = 'dynamic'
+ self._add_hdd_attribute_line()
+
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ self._add_exclude(computer_ram_16, computer_hdd_1)
+
+ # CASE: variant does not exist, but combination is not possible
+ # so it should not create it
+ impossible_combination = computer_ssd_256 + computer_ram_16 + computer_hdd_1
+ Product = self.env['product.product']
+ self.assertEqual(self.computer._create_product_variant(impossible_combination), Product)
+
+ # CASE: the variant does not exist, and the combination is possible, so
+ # it should create it
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+ variant = self.computer._create_product_variant(combination)
+ self.assertTrue(variant)
+
+ # CASE: the variant already exists, so it should return it
+ self.assertEqual(variant, self.computer._create_product_variant(combination))
+
+ def _add_keyboard_attribute(self):
+ self.keyboard_attribute = self.env['product.attribute'].create({
+ 'name': 'Keyboard',
+ 'sequence': 6,
+ 'create_variant': 'dynamic',
+ })
+ self.keyboard_included = self.env['product.attribute.value'].create({
+ 'name': 'Included',
+ 'attribute_id': self.keyboard_attribute.id,
+ 'sequence': 1,
+ })
+ self.keyboard_excluded = self.env['product.attribute.value'].create({
+ 'name': 'Excluded',
+ 'attribute_id': self.keyboard_attribute.id,
+ 'sequence': 2,
+ })
+ self.computer_keyboard_attribute_lines = self.env['product.template.attribute.line'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'attribute_id': self.keyboard_attribute.id,
+ 'value_ids': [(6, 0, [self.keyboard_included.id, self.keyboard_excluded.id])],
+ })
+ self.computer_keyboard_attribute_lines.product_template_value_ids[0].price_extra = 5
+ self.computer_keyboard_attribute_lines.product_template_value_ids[1].price_extra = -5
diff --git a/addons/sale/tests/test_sale_refund.py b/addons/sale/tests/test_sale_refund.py
new file mode 100644
index 00000000..094fe8dc
--- /dev/null
+++ b/addons/sale/tests/test_sale_refund.py
@@ -0,0 +1,319 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from .common import TestSaleCommon
+from odoo.tests import Form, tagged
+
+
+@tagged('post_install', '-at_install')
+class TestSaleToInvoice(TestSaleCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ # Create the SO with four order lines
+ cls.sale_order = cls.env['sale.order'].with_context(tracking_disable=True).create({
+ 'partner_id': cls.partner_a.id,
+ 'partner_invoice_id': cls.partner_a.id,
+ 'partner_shipping_id': cls.partner_a.id,
+ 'pricelist_id': cls.company_data['default_pricelist'].id,
+ })
+ SaleOrderLine = cls.env['sale.order.line'].with_context(tracking_disable=True)
+ cls.sol_prod_order = SaleOrderLine.create({
+ 'name': cls.company_data['product_order_no'].name,
+ 'product_id': cls.company_data['product_order_no'].id,
+ 'product_uom_qty': 5,
+ 'product_uom': cls.company_data['product_order_no'].uom_id.id,
+ 'price_unit': cls.company_data['product_order_no'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_serv_deliver = SaleOrderLine.create({
+ 'name': cls.company_data['product_service_delivery'].name,
+ 'product_id': cls.company_data['product_service_delivery'].id,
+ 'product_uom_qty': 4,
+ 'product_uom': cls.company_data['product_service_delivery'].uom_id.id,
+ 'price_unit': cls.company_data['product_service_delivery'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_serv_order = SaleOrderLine.create({
+ 'name': cls.company_data['product_service_order'].name,
+ 'product_id': cls.company_data['product_service_order'].id,
+ 'product_uom_qty': 3,
+ 'product_uom': cls.company_data['product_service_order'].uom_id.id,
+ 'price_unit': cls.company_data['product_service_order'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_prod_deliver = SaleOrderLine.create({
+ 'name': cls.company_data['product_delivery_no'].name,
+ 'product_id': cls.company_data['product_delivery_no'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_delivery_no'].uom_id.id,
+ 'price_unit': cls.company_data['product_delivery_no'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+
+ # Confirm the SO
+ cls.sale_order.action_confirm()
+
+ # Create an invoice with invoiceable lines only
+ payment = cls.env['sale.advance.payment.inv'].with_context({
+ 'active_model': 'sale.order',
+ 'active_ids': [cls.sale_order.id],
+ 'active_id': cls.sale_order.id,
+ 'default_journal_id': cls.company_data['default_journal_sale'].id,
+ }).create({
+ 'advance_payment_method': 'delivered'
+ })
+ payment.create_invoices()
+
+ cls.invoice = cls.sale_order.invoice_ids[0]
+
+ def test_refund_create(self):
+ # Validate invoice
+ self.invoice.action_post()
+
+ # Check quantity to invoice on SO lines
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ self.assertFalse(line.invoice_lines, "The line based on delivered qty are not invoiced, so they should not be linked to invoice line")
+ else:
+ if line == self.sol_prod_order:
+ self.assertEqual(line.qty_to_invoice, 0.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
+ self.assertEqual(line.qty_invoiced, 5.0, "The ordered (prod) sale line are totally invoiced (qty invoiced come from the invoice lines)")
+ else:
+ self.assertEqual(line.qty_to_invoice, 0.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
+ self.assertEqual(line.qty_invoiced, 3.0, "The ordered (serv) sale line are totally invoiced (qty invoiced = the invoice lines)")
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(len(line.invoice_lines), 1, "The lines 'ordered' qty are invoiced, so it should be linked to 1 invoice lines")
+
+ # Make a credit note
+ credit_note_wizard = self.env['account.move.reversal'].with_context({'active_ids': [self.invoice.id], 'active_id': self.invoice.id, 'active_model': 'account.move'}).create({
+ 'refund_method': 'refund', # this is the only mode for which the SO line is linked to the refund (https://github.com/odoo/odoo/commit/e680f29560ac20133c7af0c6364c6ef494662eac)
+ 'reason': 'reason test create',
+ })
+ credit_note_wizard.reverse_moves()
+ invoice_refund = self.sale_order.invoice_ids.sorted(key=lambda inv: inv.id, reverse=False)[-1] # the first invoice, its refund, and the new invoice
+
+ # Check invoice's type and number
+ self.assertEqual(invoice_refund.move_type, 'out_refund', 'The last created invoiced should be a refund')
+ self.assertEqual(invoice_refund.state, 'draft', 'Last Customer invoices should be in draft')
+ self.assertEqual(self.sale_order.invoice_count, 2, "The SO should have 2 related invoices: the original, the new refund")
+ self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_refund')), 1, "The SO should be linked to only one refund")
+ self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_invoice')), 1, "The SO should be linked to only one customer invoices")
+
+ # At this time, the invoice 1 is opend (validated) and its refund is in draft, so the amounts invoiced are not zero for
+ # invoiced sale line. The amounts only take validated invoice/refund into account.
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO line based on delivered qty")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ self.assertFalse(line.invoice_lines, "The line based on delivered are not invoiced, so they should not be linked to invoice line")
+ else:
+ if line == self.sol_prod_order:
+ self.assertEqual(line.qty_to_invoice, 5.0, "As the refund is created, the invoiced quantity cancel each other (consu ordered)")
+ self.assertEqual(line.qty_invoiced, 0.0, "The qty to invoice should have decreased as the refund is created for ordered consu SO line")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "Amount to invoice is zero as the refund is not validated")
+ self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * 5, "Amount invoiced is now set as unit price * ordered qty - refund qty) even if the ")
+ self.assertEqual(len(line.invoice_lines), 2, "The line 'ordered consumable' is invoiced, so it should be linked to 2 invoice lines (invoice and refund)")
+ else:
+ self.assertEqual(line.qty_to_invoice, 3.0, "As the refund is created, the invoiced quantity cancel each other (consu ordered)")
+ self.assertEqual(line.qty_invoiced, 0.0, "The qty to invoice should have decreased as the refund is created for ordered service SO line")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "Amount to invoice is zero as the refund is not validated")
+ self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * 3, "Amount invoiced is now set as unit price * ordered qty - refund qty) even if the ")
+ self.assertEqual(len(line.invoice_lines), 2, "The line 'ordered service' is invoiced, so it should be linked to 2 invoice lines (invoice and refund)")
+
+ # Validate the refund
+ invoice_refund.action_post()
+
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ self.assertFalse(line.invoice_lines, "The line based on delivered are not invoiced, so they should not be linked to invoice line")
+ else:
+ if line == self.sol_prod_order:
+ self.assertEqual(line.qty_to_invoice, 5.0, "As the refund still exists, the quantity to invoice is the ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "The qty to invoice should be zero as, with the refund, the quantities cancel each other")
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * 5, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, as refund is validated")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "Amount invoiced decreased as the refund is now confirmed")
+ self.assertEqual(len(line.invoice_lines), 2, "The line 'ordered consumable' is invoiced, so it should be linked to 2 invoice lines (invoice and refund)")
+ else:
+ self.assertEqual(line.qty_to_invoice, 3.0, "As the refund still exists, the quantity to invoice is the ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "The qty to invoice should be zero as, with the refund, the quantities cancel each other")
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * 3, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, as refund is validated")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "Amount invoiced decreased as the refund is now confirmed")
+ self.assertEqual(len(line.invoice_lines), 2, "The line 'ordered service' is invoiced, so it should be linked to 2 invoice lines (invoice and refund)")
+
+ def test_refund_cancel(self):
+ """ Test invoice with a refund in 'cancel' mode, meaning a refund will be created and auto confirm to completely cancel the first
+ customer invoice. The SO will have 2 invoice (customer + refund) in a paid state at the end. """
+ # Increase quantity of an invoice lines
+ with Form(self.invoice) as invoice_form:
+ with invoice_form.invoice_line_ids.edit(0) as line_form:
+ line_form.quantity = 6
+ with invoice_form.invoice_line_ids.edit(1) as line_form:
+ line_form.quantity = 4
+
+ # Validate invoice
+ self.invoice.action_post()
+
+ # Check quantity to invoice on SO lines
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ self.assertFalse(line.invoice_lines, "The line based on delivered qty are not invoiced, so they should not be linked to invoice line")
+ else:
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(len(line.invoice_lines), 1, "The lines 'ordered' qty are invoiced, so it should be linked to 1 invoice lines")
+
+ self.assertEqual(line.qty_invoiced, line.product_uom_qty + 1, "The quantity invoiced is +1 unit from the one of the sale line, as we modified invoice lines (%s)" % (line.name,))
+ self.assertEqual(line.qty_to_invoice, -1, "The quantity to invoice is negative as we invoice more than ordered")
+
+ # Make a credit note
+ credit_note_wizard = self.env['account.move.reversal'].with_context({'active_ids': self.invoice.ids, 'active_id': self.invoice.id, 'active_model': 'account.move'}).create({
+ 'refund_method': 'cancel',
+ 'reason': 'reason test cancel',
+ })
+ invoice_refund = self.env['account.move'].browse(credit_note_wizard.reverse_moves()['res_id'])
+
+ # Check invoice's type and number
+ self.assertEqual(invoice_refund.move_type, 'out_refund', 'The last created invoiced should be a customer invoice')
+ self.assertEqual(invoice_refund.payment_state, 'paid', 'Last Customer creadit note should be in paid state')
+ self.assertEqual(self.sale_order.invoice_count, 2, "The SO should have 3 related invoices: the original, the refund, and the new one")
+ self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_refund')), 1, "The SO should be linked to only one refund")
+ self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_invoice')), 1, "The SO should be linked to only one customer invoices")
+
+ # At this time, the invoice 1 is opened (validated) and its refund validated too, so the amounts invoiced are zero for
+ # all sale line. All invoiceable Sale lines have
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO line based on delivered qty")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ self.assertFalse(line.invoice_lines, "The line based on delivered are not invoiced, so they should not be linked to invoice line")
+ else:
+ self.assertEqual(line.qty_to_invoice, line.product_uom_qty, "The quantity to invoice should be the ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0, "The quantity invoiced is zero as the refund (paid) completely cancel the first invoice")
+
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(len(line.invoice_lines), 2, "The lines 'ordered' qty are invoiced, so it should be linked to 1 invoice lines")
+
+ def test_refund_modify(self):
+ """ Test invoice with a refund in 'modify' mode, and check customer invoices credit note is created from respective invoice """
+ # Decrease quantity of an invoice lines
+ with Form(self.invoice) as invoice_form:
+ with invoice_form.invoice_line_ids.edit(0) as line_form:
+ line_form.quantity = 3
+ with invoice_form.invoice_line_ids.edit(1) as line_form:
+ line_form.quantity = 2
+
+ # Validate invoice
+ self.invoice.action_post()
+
+ # Check quantity to invoice on SO lines
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ self.assertFalse(line.invoice_lines, "The line based on delivered qty are not invoiced, so they should not be linked to invoice line")
+ else:
+ if line == self.sol_prod_order:
+ self.assertEqual(line.qty_to_invoice, 2.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
+ self.assertEqual(line.qty_invoiced, 3.0, "The ordered (prod) sale line are totally invoiced (qty invoiced come from the invoice lines)")
+ else:
+ self.assertEqual(line.qty_to_invoice, 1.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
+ self.assertEqual(line.qty_invoiced, 2.0, "The ordered (serv) sale line are totally invoiced (qty invoiced = the invoice lines)")
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(len(line.invoice_lines), 1, "The lines 'ordered' qty are invoiced, so it should be linked to 1 invoice lines")
+
+ # Make a credit note
+ credit_note_wizard = self.env['account.move.reversal'].with_context({'active_ids': [self.invoice.id], 'active_id': self.invoice.id, 'active_model': 'account.move'}).create({
+ 'refund_method': 'modify', # this is the only mode for which the SO line is linked to the refund (https://github.com/odoo/odoo/commit/e680f29560ac20133c7af0c6364c6ef494662eac)
+ 'reason': 'reason test modify',
+ })
+ invoice_refund = self.env['account.move'].browse(credit_note_wizard.reverse_moves()['res_id'])
+
+ # Check invoice's type and number
+ self.assertEqual(invoice_refund.move_type, 'out_invoice', 'The last created invoiced should be a customer invoice')
+ self.assertEqual(invoice_refund.state, 'draft', 'Last Customer invoices should be in draft')
+ self.assertEqual(self.sale_order.invoice_count, 3, "The SO should have 3 related invoices: the original, the refund, and the new one")
+ self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_refund')), 1, "The SO should be linked to only one refund")
+ self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_invoice')), 2, "The SO should be linked to two customer invoices")
+
+ # At this time, the invoice 1 and its refund are confirmed, so the amounts invoiced are zero. The third invoice
+ # (2nd customer inv) is in draft state.
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ self.assertFalse(line.invoice_lines, "The line based on delivered are not invoiced, so they should not be linked to invoice line")
+ else:
+ if line == self.sol_prod_order:
+ self.assertEqual(line.qty_to_invoice, 2.0, "The qty to invoice does not change when confirming the new invoice (2)")
+ self.assertEqual(line.qty_invoiced, 3.0, "The ordered (prod) sale line does not change on invoice 2 confirmation")
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * 5, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "Amount invoiced is zero as the invoice 1 and its refund are reconcilied")
+ self.assertEqual(len(line.invoice_lines), 3, "The line 'ordered consumable' is invoiced, so it should be linked to 3 invoice lines (invoice and refund)")
+ else:
+ self.assertEqual(line.qty_to_invoice, 1.0, "The qty to invoice does not change when confirming the new invoice (2)")
+ self.assertEqual(line.qty_invoiced, 2.0, "The ordered (serv) sale line does not change on invoice 2 confirmation")
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * 3, "Amount to invoice is now set as unit price * ordered qty - refund qty) even if the ")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "Amount invoiced is zero as the invoice 1 and its refund are reconcilied")
+ self.assertEqual(len(line.invoice_lines), 3, "The line 'ordered service' is invoiced, so it should be linked to 3 invoice lines (invoice and refund)")
+
+ # Change unit of ordered product on refund lines
+ move_form = Form(invoice_refund)
+ with move_form.invoice_line_ids.edit(0) as line_form:
+ line_form.price_unit = 100
+ with move_form.invoice_line_ids.edit(1) as line_form:
+ line_form.price_unit = 50
+ invoice_refund = move_form.save()
+
+ # Validate the refund
+ invoice_refund.action_post()
+
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ self.assertFalse(line.invoice_lines, "The line based on delivered are not invoiced, so they should not be linked to invoice line, even after validation")
+ else:
+ if line == self.sol_prod_order:
+ self.assertEqual(line.qty_to_invoice, 2.0, "The qty to invoice does not change when confirming the new invoice (3)")
+ self.assertEqual(line.qty_invoiced, 3.0, "The ordered sale line are totally invoiced (qty invoiced = ordered qty)")
+ self.assertEqual(line.untaxed_amount_to_invoice, 1100.0, "")
+ self.assertEqual(line.untaxed_amount_invoiced, 300.0, "")
+ self.assertEqual(len(line.invoice_lines), 3, "The line 'ordered consumable' is invoiced, so it should be linked to 2 invoice lines (invoice and refund), even after validation")
+ else:
+ self.assertEqual(line.qty_to_invoice, 1.0, "The qty to invoice does not change when confirming the new invoice (3)")
+ self.assertEqual(line.qty_invoiced, 2.0, "The ordered sale line are totally invoiced (qty invoiced = ordered qty)")
+ self.assertEqual(line.untaxed_amount_to_invoice, 170.0, "")
+ self.assertEqual(line.untaxed_amount_invoiced, 100.0, "")
+ self.assertEqual(len(line.invoice_lines), 3, "The line 'ordered service' is invoiced, so it should be linked to 2 invoice lines (invoice and refund), even after validation")
diff --git a/addons/sale/tests/test_sale_signature.py b/addons/sale/tests/test_sale_signature.py
new file mode 100644
index 00000000..bd06b8bb
--- /dev/null
+++ b/addons/sale/tests/test_sale_signature.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.base.tests.common import HttpCaseWithUserPortal
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestSaleSignature(HttpCaseWithUserPortal):
+ def test_01_portal_sale_signature_tour(self):
+ """The goal of this test is to make sure the portal user can sign SO."""
+
+ portal_user = self.partner_portal
+ # create a SO to be signed
+ sales_order = self.env['sale.order'].create({
+ 'name': 'test SO',
+ 'partner_id': portal_user.id,
+ 'state': 'sent',
+ 'require_payment': False,
+ })
+ self.env['sale.order.line'].create({
+ 'order_id': sales_order.id,
+ 'product_id': self.env['product.product'].create({'name': 'A product'}).id,
+ })
+
+ # must be sent to the user so he can see it
+ email_act = sales_order.action_quotation_send()
+ email_ctx = email_act.get('context', {})
+ sales_order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
+
+ self.start_tour("/", 'sale_signature', login="portal")
diff --git a/addons/sale/tests/test_sale_to_invoice.py b/addons/sale/tests/test_sale_to_invoice.py
new file mode 100644
index 00000000..4291e735
--- /dev/null
+++ b/addons/sale/tests/test_sale_to_invoice.py
@@ -0,0 +1,390 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tools import float_is_zero
+from .common import TestSaleCommon
+from odoo.tests import Form, tagged
+
+
+@tagged('-at_install', 'post_install')
+class TestSaleToInvoice(TestSaleCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ # Create the SO with four order lines
+ cls.sale_order = cls.env['sale.order'].with_context(tracking_disable=True).create({
+ 'partner_id': cls.partner_a.id,
+ 'partner_invoice_id': cls.partner_a.id,
+ 'partner_shipping_id': cls.partner_a.id,
+ 'pricelist_id': cls.company_data['default_pricelist'].id,
+ })
+ SaleOrderLine = cls.env['sale.order.line'].with_context(tracking_disable=True)
+ cls.sol_prod_order = SaleOrderLine.create({
+ 'name': cls.company_data['product_order_no'].name,
+ 'product_id': cls.company_data['product_order_no'].id,
+ 'product_uom_qty': 5,
+ 'product_uom': cls.company_data['product_order_no'].uom_id.id,
+ 'price_unit': cls.company_data['product_order_no'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_serv_deliver = SaleOrderLine.create({
+ 'name': cls.company_data['product_service_delivery'].name,
+ 'product_id': cls.company_data['product_service_delivery'].id,
+ 'product_uom_qty': 4,
+ 'product_uom': cls.company_data['product_service_delivery'].uom_id.id,
+ 'price_unit': cls.company_data['product_service_delivery'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_serv_order = SaleOrderLine.create({
+ 'name': cls.company_data['product_service_order'].name,
+ 'product_id': cls.company_data['product_service_order'].id,
+ 'product_uom_qty': 3,
+ 'product_uom': cls.company_data['product_service_order'].uom_id.id,
+ 'price_unit': cls.company_data['product_service_order'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+ cls.sol_prod_deliver = SaleOrderLine.create({
+ 'name': cls.company_data['product_delivery_no'].name,
+ 'product_id': cls.company_data['product_delivery_no'].id,
+ 'product_uom_qty': 2,
+ 'product_uom': cls.company_data['product_delivery_no'].uom_id.id,
+ 'price_unit': cls.company_data['product_delivery_no'].list_price,
+ 'order_id': cls.sale_order.id,
+ 'tax_id': False,
+ })
+
+ # Context
+ cls.context = {
+ 'active_model': 'sale.order',
+ 'active_ids': [cls.sale_order.id],
+ 'active_id': cls.sale_order.id,
+ 'default_journal_id': cls.company_data['default_journal_sale'].id,
+ }
+
+ def _check_order_search(self, orders, domain, expected_result):
+ domain += [('id', 'in', orders.ids)]
+ result = self.env['sale.order'].search(domain)
+ self.assertEqual(result, expected_result, "Unexpected result on search orders")
+
+ def test_search_invoice_ids(self):
+ """Test searching on computed fields invoice_ids"""
+
+ # Make qty zero to have a line without invoices
+ self.sol_prod_order.product_uom_qty = 0
+ self.sale_order.action_confirm()
+
+ # Tests before creating an invoice
+ self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order)
+ self._check_order_search(self.sale_order, [('invoice_ids', '!=', False)], self.env['sale.order'])
+
+ # Create invoice
+ moves = self.sale_order._create_invoices()
+
+ # Tests after creating the invoice
+ self._check_order_search(self.sale_order, [('invoice_ids', 'in', moves.ids)], self.sale_order)
+ self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order'])
+ self._check_order_search(self.sale_order, [('invoice_ids', '!=', False)], self.sale_order)
+
+ def test_downpayment(self):
+ """ Test invoice with a way of downpayment and check downpayment's SO line is created
+ and also check a total amount of invoice is equal to a respective sale order's total amount
+ """
+ # Confirm the SO
+ self.sale_order.action_confirm()
+ self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order)
+ # Let's do an invoice for a deposit of 100
+ downpayment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
+ 'advance_payment_method': 'fixed',
+ 'fixed_amount': 50,
+ 'deposit_account_id': self.company_data['default_account_revenue'].id
+ })
+ downpayment.create_invoices()
+ downpayment2 = self.env['sale.advance.payment.inv'].with_context(self.context).create({
+ 'advance_payment_method': 'fixed',
+ 'fixed_amount': 50,
+ 'deposit_account_id': self.company_data['default_account_revenue'].id
+ })
+ downpayment2.create_invoices()
+ self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order'])
+
+ self.assertEqual(len(self.sale_order.invoice_ids), 2, 'Invoice should be created for the SO')
+ downpayment_line = self.sale_order.order_line.filtered(lambda l: l.is_downpayment)
+ self.assertEqual(len(downpayment_line), 2, 'SO line downpayment should be created on SO')
+
+ # Update delivered quantity of SO lines
+ self.sol_serv_deliver.write({'qty_delivered': 4.0})
+ self.sol_prod_deliver.write({'qty_delivered': 2.0})
+
+ # Let's do an invoice with refunds
+ payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
+ 'deposit_account_id': self.company_data['default_account_revenue'].id
+ })
+ payment.create_invoices()
+
+ self.assertEqual(len(self.sale_order.invoice_ids), 3, 'Invoice should be created for the SO')
+
+ invoice = max(self.sale_order.invoice_ids)
+ self.assertEqual(len(invoice.invoice_line_ids.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))), len(self.sale_order.order_line), 'All lines should be invoiced')
+ self.assertEqual(len(invoice.invoice_line_ids.filtered(lambda l: l.display_type == 'line_section' and l.name == "Down Payments")), 1, 'A single section for downpayments should be present')
+ self.assertEqual(invoice.amount_total, self.sale_order.amount_total - sum(downpayment_line.mapped('price_unit')), 'Downpayment should be applied')
+
+ def test_downpayment_percentage_tax_icl(self):
+ """ Test invoice with a percentage downpayment and an included tax
+ Check the total amount of invoice is correct and equal to a respective sale order's total amount
+ """
+ # Confirm the SO
+ self.sale_order.action_confirm()
+ tax_downpayment = self.company_data['default_tax_sale'].copy({'price_include': True})
+ # Let's do an invoice for a deposit of 100
+ product_id = self.env['ir.config_parameter'].sudo().get_param('sale.default_deposit_product_id')
+ product_id = self.env['product.product'].browse(int(product_id)).exists()
+ product_id.taxes_id = tax_downpayment.ids
+ payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
+ 'advance_payment_method': 'percentage',
+ 'amount': 50,
+ 'deposit_account_id': self.company_data['default_account_revenue'].id,
+ })
+ payment.create_invoices()
+
+ self.assertEqual(len(self.sale_order.invoice_ids), 1, 'Invoice should be created for the SO')
+ downpayment_line = self.sale_order.order_line.filtered(lambda l: l.is_downpayment)
+ self.assertEqual(len(downpayment_line), 1, 'SO line downpayment should be created on SO')
+ self.assertEqual(downpayment_line.price_unit, self.sale_order.amount_total/2, 'downpayment should have the correct amount')
+
+ invoice = self.sale_order.invoice_ids[0]
+ downpayment_aml = invoice.line_ids.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))[0]
+ self.assertEqual(downpayment_aml.price_total, self.sale_order.amount_total/2, 'downpayment should have the correct amount')
+ self.assertEqual(downpayment_aml.price_unit, self.sale_order.amount_total/2, 'downpayment should have the correct amount')
+ invoice.action_post()
+ self.assertEqual(downpayment_line.price_unit, self.sale_order.amount_total/2, 'downpayment should have the correct amount')
+
+ def test_invoice_with_discount(self):
+ """ Test invoice with a discount and check discount applied on both SO lines and an invoice lines """
+ # Update discount and delivered quantity on SO lines
+ self.sol_prod_order.write({'discount': 20.0})
+ self.sol_serv_deliver.write({'discount': 20.0, 'qty_delivered': 4.0})
+ self.sol_serv_order.write({'discount': -10.0})
+ self.sol_prod_deliver.write({'qty_delivered': 2.0})
+
+ for line in self.sale_order.order_line.filtered(lambda l: l.discount):
+ product_price = line.price_unit * line.product_uom_qty
+ self.assertEqual(line.discount, (product_price - line.price_subtotal) / product_price * 100, 'Discount should be applied on order line')
+
+ # lines are in draft
+ for line in self.sale_order.order_line:
+ self.assertTrue(float_is_zero(line.untaxed_amount_to_invoice, precision_digits=2), "The amount to invoice should be zero, as the line is in draf state")
+ self.assertTrue(float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state")
+
+ self.sale_order.action_confirm()
+
+ for line in self.sale_order.order_line:
+ self.assertTrue(float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state")
+
+ self.assertEqual(self.sol_serv_order.untaxed_amount_to_invoice, 297, "The untaxed amount to invoice is wrong")
+ self.assertEqual(self.sol_serv_deliver.untaxed_amount_to_invoice, self.sol_serv_deliver.qty_delivered * self.sol_serv_deliver.price_reduce, "The untaxed amount to invoice should be qty deli * price reduce, so 4 * (180 - 36)")
+ # 'untaxed_amount_to_invoice' is invalid when 'sale_stock' is installed.
+ # self.assertEqual(self.sol_prod_deliver.untaxed_amount_to_invoice, 140, "The untaxed amount to invoice should be qty deli * price reduce, so 4 * (180 - 36)")
+
+ # Let's do an invoice with invoiceable lines
+ payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
+ 'advance_payment_method': 'delivered'
+ })
+ self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order)
+ payment.create_invoices()
+ self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order'])
+ invoice = self.sale_order.invoice_ids[0]
+ invoice.action_post()
+
+ # Check discount appeared on both SO lines and invoice lines
+ for line, inv_line in zip(self.sale_order.order_line, invoice.invoice_line_ids):
+ self.assertEqual(line.discount, inv_line.discount, 'Discount on lines of order and invoice should be same')
+
+ def test_invoice(self):
+ """ Test create and invoice from the SO, and check qty invoice/to invoice, and the related amounts """
+ # lines are in draft
+ for line in self.sale_order.order_line:
+ self.assertTrue(float_is_zero(line.untaxed_amount_to_invoice, precision_digits=2), "The amount to invoice should be zero, as the line is in draf state")
+ self.assertTrue(float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state")
+
+ # Confirm the SO
+ self.sale_order.action_confirm()
+
+ # Check ordered quantity, quantity to invoice and invoiced quantity of SO lines
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, 'Quantity to invoice should be same as ordered quantity')
+ self.assertEqual(line.qty_invoiced, 0.0, 'Invoiced quantity should be zero as no any invoice created for SO')
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ else:
+ self.assertEqual(line.qty_to_invoice, line.product_uom_qty, 'Quantity to invoice should be same as ordered quantity')
+ self.assertEqual(line.qty_invoiced, 0.0, 'Invoiced quantity should be zero as no any invoice created for SO')
+ self.assertEqual(line.untaxed_amount_to_invoice, line.product_uom_qty * line.price_unit, "The amount to invoice should the total of the line, as the line is confirmed")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line is confirmed")
+
+ # Let's do an invoice with invoiceable lines
+ payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
+ 'advance_payment_method': 'delivered'
+ })
+ payment.create_invoices()
+
+ invoice = self.sale_order.invoice_ids[0]
+
+ # Update quantity of an invoice lines
+ move_form = Form(invoice)
+ with move_form.invoice_line_ids.edit(0) as line_form:
+ line_form.quantity = 3.0
+ with move_form.invoice_line_ids.edit(1) as line_form:
+ line_form.quantity = 2.0
+ invoice = move_form.save()
+
+ # amount to invoice / invoiced should not have changed (amounts take only confirmed invoice into account)
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be zero")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as delivered lines are not delivered yet")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity (no confirmed invoice)")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as no invoice are validated for now")
+ else:
+ if line == self.sol_prod_order:
+ self.assertEqual(self.sol_prod_order.qty_to_invoice, 2.0, "Changing the quantity on draft invoice update the qty to invoice on SO lines")
+ self.assertEqual(self.sol_prod_order.qty_invoiced, 3.0, "Changing the quantity on draft invoice update the invoiced qty on SO lines")
+ else:
+ self.assertEqual(self.sol_serv_order.qty_to_invoice, 1.0, "Changing the quantity on draft invoice update the qty to invoice on SO lines")
+ self.assertEqual(self.sol_serv_order.qty_invoiced, 2.0, "Changing the quantity on draft invoice update the invoiced qty on SO lines")
+ self.assertEqual(line.untaxed_amount_to_invoice, line.product_uom_qty * line.price_unit, "The amount to invoice should the total of the line, as the line is confirmed (no confirmed invoice)")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as no invoice are validated for now")
+
+ invoice.action_post()
+
+ # Check quantity to invoice on SO lines
+ for line in self.sale_order.order_line:
+ if line.product_id.invoice_policy == 'delivery':
+ self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
+ self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
+ self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
+ self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
+ else:
+ if line == self.sol_prod_order:
+ self.assertEqual(line.qty_to_invoice, 2.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
+ self.assertEqual(line.qty_invoiced, 3.0, "The ordered (prod) sale line are totally invoiced (qty invoiced come from the invoice lines)")
+ else:
+ self.assertEqual(line.qty_to_invoice, 1.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
+ self.assertEqual(line.qty_invoiced, 2.0, "The ordered (serv) sale line are totally invoiced (qty invoiced = the invoice lines)")
+ self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
+ self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products")
+
+ def test_invoice_with_sections(self):
+ """ Test create and invoice with sections from the SO, and check qty invoice/to invoice, and the related amounts """
+
+ sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
+ 'partner_id': self.partner_a.id,
+ 'partner_invoice_id': self.partner_a.id,
+ 'partner_shipping_id': self.partner_a.id,
+ 'pricelist_id': self.company_data['default_pricelist'].id,
+ })
+
+ SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True)
+ SaleOrderLine.create({
+ 'name': 'Section',
+ 'display_type': 'line_section',
+ 'order_id': sale_order.id,
+ })
+ sol_prod_deliver = SaleOrderLine.create({
+ 'name': self.company_data['product_order_no'].name,
+ 'product_id': self.company_data['product_order_no'].id,
+ 'product_uom_qty': 5,
+ 'product_uom': self.company_data['product_order_no'].uom_id.id,
+ 'price_unit': self.company_data['product_order_no'].list_price,
+ 'order_id': sale_order.id,
+ 'tax_id': False,
+ })
+
+ # Confirm the SO
+ sale_order.action_confirm()
+
+ sol_prod_deliver.write({'qty_delivered': 5.0})
+
+ # Context
+ self.context = {
+ 'active_model': 'sale.order',
+ 'active_ids': [sale_order.id],
+ 'active_id': sale_order.id,
+ 'default_journal_id': self.company_data['default_journal_sale'].id,
+ }
+
+ # Let's do an invoice with invoiceable lines
+ payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
+ 'advance_payment_method': 'delivered'
+ })
+ payment.create_invoices()
+
+ invoice = sale_order.invoice_ids[0]
+
+ self.assertEqual(invoice.line_ids[0].display_type, 'line_section')
+
+ def test_qty_invoiced(self):
+ """Verify uom rounding is correctly considered during qty_invoiced compute"""
+ sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
+ 'partner_id': self.partner_a.id,
+ 'partner_invoice_id': self.partner_a.id,
+ 'partner_shipping_id': self.partner_a.id,
+ 'pricelist_id': self.company_data['default_pricelist'].id,
+ })
+
+ SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True)
+ sol_prod_deliver = SaleOrderLine.create({
+ 'name': self.company_data['product_order_no'].name,
+ 'product_id': self.company_data['product_order_no'].id,
+ 'product_uom_qty': 5,
+ 'product_uom': self.company_data['product_order_no'].uom_id.id,
+ 'price_unit': self.company_data['product_order_no'].list_price,
+ 'order_id': sale_order.id,
+ 'tax_id': False,
+ })
+
+ # Confirm the SO
+ sale_order.action_confirm()
+
+ sol_prod_deliver.write({'qty_delivered': 5.0})
+ # Context
+ self.context = {
+ 'active_model': 'sale.order',
+ 'active_ids': [sale_order.id],
+ 'active_id': sale_order.id,
+ 'default_journal_id': self.company_data['default_journal_sale'].id,
+ }
+
+ # Let's do an invoice with invoiceable lines
+ invoicing_wizard = self.env['sale.advance.payment.inv'].with_context(self.context).create({
+ 'advance_payment_method': 'delivered'
+ })
+ invoicing_wizard.create_invoices()
+
+ self.assertEqual(sol_prod_deliver.qty_invoiced, 5.0)
+ # We would have to change the digits of the field to
+ # test a greater decimal precision.
+ quantity = 5.13
+ move_form = Form(sale_order.invoice_ids)
+ with move_form.invoice_line_ids.edit(0) as line_form:
+ line_form.quantity = quantity
+ move_form.save()
+
+ # Default uom rounding to 0.01
+ qty_invoiced_field = sol_prod_deliver._fields.get('qty_invoiced')
+ sol_prod_deliver.env.add_to_compute(qty_invoiced_field, sol_prod_deliver)
+ self.assertEqual(sol_prod_deliver.qty_invoiced, quantity)
+
+ # Rounding to 0.1, should be rounded with UP (ceil) rounding_method
+ # Not floor or half up rounding.
+ sol_prod_deliver.product_uom.rounding *= 10
+ sol_prod_deliver.product_uom.flush(['rounding'])
+ expected_qty = 5.2
+ qty_invoiced_field = sol_prod_deliver._fields.get('qty_invoiced')
+ sol_prod_deliver.env.add_to_compute(qty_invoiced_field, sol_prod_deliver)
+ self.assertEqual(sol_prod_deliver.qty_invoiced, expected_qty)
diff --git a/addons/sale/tests/test_sale_transaction.py b/addons/sale/tests/test_sale_transaction.py
new file mode 100644
index 00000000..cd7c5dad
--- /dev/null
+++ b/addons/sale/tests/test_sale_transaction.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+from odoo import tests
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+from odoo.tools import mute_logger
+
+
+@tests.tagged('post_install', '-at_install')
+class TestSaleTransaction(AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ cls.company_data['company'].country_id = cls.env.ref('base.us')
+
+ cls.order = cls.env['sale.order'].create({
+ 'partner_id': cls.partner_a.id,
+ 'order_line': [
+ (0, False, {
+ 'product_id': cls.product_a.id,
+ 'name': '1 Product',
+ 'price_unit': 100.0,
+ }),
+ ],
+ })
+ cls.env.ref('payment.payment_acquirer_transfer').journal_id = cls.company_data['default_journal_cash']
+
+ cls.transaction = cls.order._create_payment_transaction({
+ 'acquirer_id': cls.env.ref('payment.payment_acquirer_transfer').id,
+ })
+
+ def test_sale_invoicing_from_transaction(self):
+ ''' Test the following scenario:
+ - Create a sale order
+ - Create a transaction for the sale order.
+ - Confirm the transaction but no invoice generated automatically.
+ - Create manually an invoice for this sale order.
+ => The invoice must be paid.
+ '''
+ self.transaction._set_transaction_done()
+ self.transaction._post_process_after_done()
+
+ # Assert a posted payment has been generated at this point.
+ self.assertTrue(self.transaction.payment_id)
+ self.assertEqual(self.transaction.payment_id.state, 'posted')
+
+ # Doesn't work with stock installed.
+ # invoice = self.order._create_invoices()
+ # invoice.post()
+ #
+ # self.assertTrue(invoice.payment_state in ('in_payment', 'paid'), "Invoice should be paid")
+
+ def test_sale_transaction_mismatch(self):
+ """Test that a transaction for the incorrect amount does not validate the SO."""
+ # modify order total
+ self.order.order_line[0].price_unit = 200.0
+ self.transaction._set_transaction_done()
+ with mute_logger('odoo.addons.sale.models.payment'):
+ self.transaction._post_process_after_done()
+ self.assertEqual(self.order.state, 'draft', 'a transaction for an incorrect amount should not validate a quote')