diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/sale/tests | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/sale/tests')
| -rw-r--r-- | addons/sale/tests/__init__.py | 14 | ||||
| -rw-r--r-- | addons/sale/tests/common.py | 199 | ||||
| -rw-r--r-- | addons/sale/tests/test_access_rights.py | 180 | ||||
| -rw-r--r-- | addons/sale/tests/test_onchange.py | 280 | ||||
| -rw-r--r-- | addons/sale/tests/test_reinvoice.py | 266 | ||||
| -rw-r--r-- | addons/sale/tests/test_sale_flow.py | 82 | ||||
| -rw-r--r-- | addons/sale/tests/test_sale_order.py | 584 | ||||
| -rw-r--r-- | addons/sale/tests/test_sale_pricelist.py | 163 | ||||
| -rw-r--r-- | addons/sale/tests/test_sale_product_attribute_value_config.py | 400 | ||||
| -rw-r--r-- | addons/sale/tests/test_sale_refund.py | 319 | ||||
| -rw-r--r-- | addons/sale/tests/test_sale_signature.py | 31 | ||||
| -rw-r--r-- | addons/sale/tests/test_sale_to_invoice.py | 390 | ||||
| -rw-r--r-- | addons/sale/tests/test_sale_transaction.py | 60 |
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') |
