19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_access_rights
@ -6,16 +5,25 @@ from . import test_accrued_sale_orders
from . import test_common
from . import test_controllers
from . import test_credit_limit
from . import test_ir_config_parameter
from . import test_onchange
from . import test_payment_flow
from . import test_reinvoice
from . import test_product_attribute_value
from . import test_product_catalog
from . import test_product_configurator_data
from . import test_sale_combo_configurator
from . import test_sale_flow
from . import test_sale_onboarding
from . import test_sale_order
from . import test_sale_order_cancel
from . import test_sale_order_product_catalog
from . import test_sale_order_ui
from . import test_sale_prices
from . import test_sale_product_attribute_value_config
from . import test_sale_product_template
from . import test_sale_refund
from . import test_sale_tax_totals
from . import test_sale_to_invoice
from . import test_sale_report
from . import test_sale_sections
from . import test_taxes_downpayment
from . import test_taxes_global_discount
from . import test_taxes_tax_totals_summary
from . import test_import_files

View file

@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import TransactionCase
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.account.tests.common import AccountTestInvoicingCommon, TestTaxCommon
from odoo.addons.product.tests.common import ProductCommon
from odoo.addons.sales_team.tests.common import SalesTeamCommon
@ -18,223 +16,239 @@ class SaleCommon(
def setUpClass(cls):
super().setUpClass()
cls.env.company.country_id = cls.env.ref('base.us')
cls.env.company.country_id = cls.quick_ref('base.us')
# Not defined in product common because only used in sale
cls.group_discount_per_so_line = cls.env.ref('product.group_discount_per_so_line')
cls.group_discount_per_so_line = cls.quick_ref('sale.group_discount_per_so_line')
cls.empty_order = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
(cls.product + cls.service_product).write({
'taxes_id': [Command.clear()],
})
cls.sale_order = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
cls._enable_pricelists()
cls.empty_order, cls.sale_order = cls.env['sale.order'].create([
{
'partner_id': cls.partner.id,
}, {
'partner_id': cls.partner.id,
'order_line': [
Command.create({
'product_id': cls.product.id,
'product_uom_qty': 5.0,
}),
Command.create({
'product_id': cls.service_product.id,
'product_uom_qty': 12.5,
})
]
},
])
@classmethod
def _enable_discounts(cls):
cls.env.user.group_ids += cls.group_discount_per_so_line
def _create_so(self, **values):
default_values = {
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': cls.consumable_product.id,
'product_uom_qty': 5.0,
'product_id': self.product.id,
}),
Command.create({
'product_id': cls.service_product.id,
'product_uom_qty': 12.5,
})
]
})
],
**values
}
return self.env['sale.order'].create(default_values)
class TestSaleCommon(AccountTestInvoicingCommon):
@classmethod
def _enable_pricelists(cls):
cls.env.user.groups_id += cls.env.ref('product.group_product_pricelist')
class TestSaleCommonBase(TransactionCase):
''' 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,
}),
def collect_company_accounting_data(cls, company):
company_data = super().collect_company_accounting_data(company)
company_data.update({
# Users
'default_user_salesman': Users.create({
'default_user_salesman': cls.env['res.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])],
'group_ids': [(6, 0, cls.quick_ref('sales_team.group_sale_salesman').ids)],
'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,
'categ_id': cls.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,
'uom_id': cls.uom_unit.id,
'default_code': 'SERV_DEL',
'invoice_policy': 'delivery',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
'company_id': company.id,
}),
'product_service_order': cls.env['product.product'].with_company(company).create({
'name': 'product_service_order',
'categ_id': company_data['product_category'].id,
'categ_id': cls.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,
'uom_id': cls.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, [])],
'company_id': company.id,
}),
'product_order_cost': cls.env['product.product'].with_company(company).create({
'name': 'product_order_cost',
'categ_id': company_data['product_category'].id,
'categ_id': cls.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,
'uom_id': cls.uom_unit.id,
'default_code': 'FURN_9999',
'invoice_policy': 'order',
'expense_policy': 'cost',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
'company_id': company.id,
}),
'product_delivery_cost': cls.env['product.product'].with_company(company).create({
'name': 'product_delivery_cost',
'categ_id': company_data['product_category'].id,
'categ_id': cls.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,
'uom_id': cls.uom_unit.id,
'default_code': 'FURN_7777',
'invoice_policy': 'delivery',
'expense_policy': 'cost',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
'company_id': company.id,
}),
'product_order_sales_price': cls.env['product.product'].with_company(company).create({
'name': 'product_order_sales_price',
'categ_id': company_data['product_category'].id,
'categ_id': cls.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,
'uom_id': cls.uom_unit.id,
'default_code': 'FURN_9999',
'invoice_policy': 'order',
'expense_policy': 'sales_price',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
'company_id': company.id,
}),
'product_delivery_sales_price': cls.env['product.product'].with_company(company).create({
'name': 'product_delivery_sales_price',
'categ_id': company_data['product_category'].id,
'categ_id': cls.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,
'uom_id': cls.uom_unit.id,
'default_code': 'FURN_7777',
'invoice_policy': 'delivery',
'expense_policy': 'sales_price',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
'company_id': company.id,
}),
'product_order_no': cls.env['product.product'].with_company(company).create({
'name': 'product_order_no',
'categ_id': company_data['product_category'].id,
'categ_id': cls.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,
'uom_id': cls.uom_unit.id,
'default_code': 'FURN_9999',
'invoice_policy': 'order',
'expense_policy': 'no',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
'company_id': company.id,
}),
'product_delivery_no': cls.env['product.product'].with_company(company).create({
'name': 'product_delivery_no',
'categ_id': company_data['product_category'].id,
'categ_id': cls.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,
'uom_id': cls.uom_unit.id,
'default_code': 'FURN_7777',
'invoice_policy': 'delivery',
'expense_policy': 'no',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
'company_id': company.id,
}),
})
return company_data
@classmethod
def get_default_groups(cls):
groups = super().get_default_groups()
return groups | cls.quick_ref('sales_team.group_sale_manager')
class TestSaleCommon(AccountTestInvoicingCommon, TestSaleCommonBase):
''' Setup to be used post-install with sale and accounting test configuration.'''
class TestTaxCommonSale(TestSaleCommon, TestTaxCommon):
@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,
def setUpClass(cls):
super().setUpClass()
cls.foreign_currency_pricelist = cls.env['product.pricelist'].create({
'name': "TestTaxCommonSale",
'currency_id': cls.foreign_currency.id,
'company_id': cls.env.company.id,
})
return company_data
def convert_document_to_sale_order(self, document):
order_date = '2020-01-01'
currency = document['currency']
self._ensure_rate(currency, order_date, document['rate'])
self.foreign_currency_pricelist.currency_id = currency
return self.env['sale.order'].create({
'date_order': order_date,
'currency_id': currency.id,
'partner_id': self.partner_a.id,
'pricelist_id': self.foreign_currency_pricelist.id,
'order_line': [
Command.create({
'name': str(i),
'product_id': (base_line['product_id'] or self.product_a).id,
'price_unit': base_line['price_unit'],
'discount': base_line['discount'],
'product_uom_qty': base_line['quantity'],
'tax_ids': [Command.set(base_line['tax_ids'].ids)],
})
for i, base_line in enumerate(document['lines'])
],
})
def assert_sale_order_tax_totals_summary(self, sale_order, expected_values, soft_checking=False):
self._assert_tax_totals_summary(sale_order.tax_totals, expected_values, soft_checking=soft_checking)
cash_rounding_base_amount_currency = sale_order.tax_totals.get('cash_rounding_base_amount_currency', 0.0)
expected_amounts = {}
if 'base_amount_currency' in expected_values:
expected_amounts['amount_untaxed'] = expected_values['base_amount_currency'] + cash_rounding_base_amount_currency
if 'tax_amount_currency' in expected_values:
expected_amounts['amount_tax'] = expected_values['tax_amount_currency']
if 'total_amount_currency' in expected_values:
expected_amounts['amount_total'] = expected_values['total_amount_currency']
self.assertRecordValues(sale_order, [expected_amounts])

View file

@ -0,0 +1,105 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo.fields import Command
from odoo.tests import HttpCase
from odoo.tools.misc import file_open
from odoo.addons.uom.tests.common import UomCommon
class TestProductConfiguratorCommon(UomCommon, HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Setup attributes and attributes values
cls.product_attribute_1 = cls.env['product.attribute'].create({
'name': 'Legs',
'sequence': 10,
})
product_attribute_value_1 = cls.env['product.attribute.value'].create({
'name': 'Steel',
'attribute_id': cls.product_attribute_1.id,
'sequence': 1,
})
product_attribute_value_2 = cls.env['product.attribute.value'].create({
'name': 'Aluminium',
'attribute_id': cls.product_attribute_1.id,
'sequence': 2,
})
product_attribute_2 = cls.env['product.attribute'].create({
'name': 'Color',
'display_type': 'color',
'sequence': 20,
})
product_attribute_value_3 = cls.env['product.attribute.value'].create({
'name': 'White',
'attribute_id': product_attribute_2.id,
'html_color': '#FFFFFF',
'sequence': 1,
})
product_attribute_value_4 = cls.env['product.attribute.value'].create({
'name': 'Black',
'attribute_id': product_attribute_2.id,
'html_color': '#000000',
'sequence': 2,
})
# Create product template
cls.product_product_custo_desk = cls.env['product.template'].create({
'name': 'Customizable Desk (TEST)',
'standard_price': 500.0,
'list_price': 750.0,
'attribute_line_ids': [
Command.create({
'attribute_id': cls.product_attribute_1.id,
'value_ids': [Command.link(product_attribute_value_1.id), Command.link(product_attribute_value_2.id)],
}),
Command.create({
'attribute_id': product_attribute_2.id,
'value_ids': [Command.link(product_attribute_value_3.id), Command.link(product_attribute_value_4.id)],
})
]
})
# Apply a price_extra for the attribute Aluminium
cls.product_product_custo_desk.attribute_line_ids[0].product_template_value_ids[1].price_extra = 50.40
# Add a Custom attribute
product_attribute_value_custom = cls.env['product.attribute.value'].create({
'name': 'Custom',
'attribute_id': cls.product_attribute_1.id,
'sequence': 3,
'is_custom': True
})
cls.product_product_custo_desk.attribute_line_ids[0].write({'value_ids': [(4, product_attribute_value_custom.id)]})
# Disable the aluminium + black product
cls.product_product_custo_desk.product_variant_ids[3].active = False
# Setup a first optional product
img_path = 'product/static/img/product_product_11-image.jpg'
img_content = base64.b64encode(file_open(img_path, "rb").read())
cls.product_product_conf_chair = cls.env['product.template'].create({
'name': 'Conference Chair (TEST)',
'image_1920': img_content,
'list_price': 16.50,
'attribute_line_ids': [
Command.create({
'attribute_id': cls.product_attribute_1.id,
'value_ids': [(4, product_attribute_value_1.id), (4, product_attribute_value_2.id)],
})
]
})
cls.product_product_conf_chair.attribute_line_ids[0].product_template_value_ids[1].price_extra = 6.40
cls.product_product_custo_desk.optional_product_ids = [(4, cls.product_product_conf_chair.id)]
# Setup a second optional product
cls.product_product_conf_chair_floor_protect = cls.env['product.template'].create({
'name': 'Chair floor protection (TEST)',
'list_price': 12.0,
})
cls.product_product_conf_chair.optional_product_ids = [(4, cls.product_product_conf_chair_floor_protect.id)]

View file

@ -1,28 +1,31 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.exceptions import AccessError, UserError
from odoo.tests import tagged
from odoo.tools import mute_logger
from odoo.addons.base.tests.common import BaseUsersCommon
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestAccessRights(BaseUsersCommon, SaleCommon):
class TestAccessRights(SaleCommon, MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_portal = cls._create_new_portal_user()
cls.user_internal = cls._create_new_internal_user()
cls.sale_user2 = cls.env['res.users'].create({
'name': 'salesman_2',
'login': 'salesman_2',
'email': 'default_user_salesman_2@example.com',
'signature': '--\nMark',
'notification_type': 'email',
'groups_id': [(6, 0, cls.group_sale_salesman.ids)],
'group_ids': [(6, 0, cls.group_sale_salesman.ids)],
})
# Create the SO with a specific salesperson
@ -91,6 +94,22 @@ class TestAccessRights(BaseUsersCommon, SaleCommon):
# Salesperson can confirm the SO
so_as_salesperson.action_confirm()
# Salesperson can't confirm the related move
move_as_salesperson = so_as_salesperson._create_invoices().with_user(self.sale_user2)
with self.assertRaises(AccessError):
move_as_salesperson.action_post()
move_as_salesperson.sudo().action_post()
composer = self.env['account.move.send.wizard']\
.with_user(self.sale_user2)\
.with_context(active_model='account.move', active_ids=move_as_salesperson.ids)\
.create({})
# Salesperson can send & print
with self.mock_mail_gateway(mail_unlink_sent=False):
composer.action_send_and_print()
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
def test_access_portal_user(self):
""" Test portal user's access rights """

View file

@ -1,18 +1,22 @@
# -*- coding: utf-8 -*-
from freezegun import freeze_time
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields
from odoo.exceptions import UserError
from odoo.fields import Command
from odoo.tests import freeze_time, tagged
from odoo.addons.sale.tests.common import TestSaleCommon
@freeze_time('2022-01-01')
@tagged('post_install', '-at_install')
class TestAccruedSaleOrders(AccountTestInvoicingCommon):
class TestAccruedSaleOrders(TestSaleCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('EUR')
cls.alt_inc_account = cls.company_data['default_account_revenue'].copy()
# set 'invoice_policy' to 'delivery' to take 'qty_delivered' into account when computing 'untaxed_amount_to_invoice'
# set 'type' to 'service' to allow manualy set 'qty_delivered' even with sale_stock installed
@ -25,7 +29,7 @@ class TestAccruedSaleOrders(AccountTestInvoicingCommon):
'invoice_policy': 'delivery',
'property_account_income_id': cls.alt_inc_account.id,
})
cls.default_plan = cls.env['account.analytic.plan'].create({'name': 'Default', 'company_id': False})
cls.default_plan = cls.env['account.analytic.plan'].create({'name': 'Default'})
cls.analytic_account_a = cls.env['account.analytic.account'].create({
'name': 'analytic_account_a',
'plan_id': cls.default_plan.id,
@ -36,11 +40,6 @@ class TestAccruedSaleOrders(AccountTestInvoicingCommon):
'plan_id': cls.default_plan.id,
'company_id': False,
})
cls.analytic_account_c = cls.env['account.analytic.account'].create({
'name': 'analytic_account_c',
'plan_id': cls.default_plan.id,
'company_id': False,
})
cls.sale_order = cls.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': cls.partner_a.id,
'order_line': [
@ -48,9 +47,8 @@ class TestAccruedSaleOrders(AccountTestInvoicingCommon):
'name': cls.product_a.name,
'product_id': cls.product_a.id,
'product_uom_qty': 10.0,
'product_uom': cls.product_a.uom_id.id,
'price_unit': cls.product_a.list_price,
'tax_id': False,
'tax_ids': False,
'analytic_distribution': {
cls.analytic_account_a.id : 80.0,
cls.analytic_account_b.id : 20.0,
@ -60,16 +58,14 @@ class TestAccruedSaleOrders(AccountTestInvoicingCommon):
'name': cls.product_b.name,
'product_id': cls.product_b.id,
'product_uom_qty': 10.0,
'product_uom': cls.product_b.uom_id.id,
'price_unit': cls.product_b.list_price,
'tax_id': False,
'tax_ids': False,
'analytic_distribution': {
cls.analytic_account_b.id : 100.0,
},
})
]
})
cls.sale_order.analytic_account_id = cls.analytic_account_c
cls.sale_order.action_confirm()
cls.account_expense = cls.company_data['default_account_expense']
cls.account_revenue = cls.company_data['default_account_revenue']
@ -78,16 +74,23 @@ class TestAccruedSaleOrders(AccountTestInvoicingCommon):
'active_ids': cls.sale_order.ids,
}).create({
'account_id': cls.account_expense.id,
'date': fields.Date.today(),
})
def test_accrued_order(self):
# self.wizard = self.wizard.with_context(accrual_entry_date=fields.Date.today())
self.wizard.date = fields.Date.today()
# nothing to invoice : no entries to be created
with self.assertRaises(UserError):
self.wizard.create_entries()
# 5 qty of each product invoiceable
self.sale_order.order_line.qty_delivered = 5
self.assertRecordValues(self.env['account.move'].search(self.wizard.create_entries()['domain']).line_ids, [
# Call accrual wizard at today date because calling in the past will
# re-compute delivred and invoiced quantities for this date and thus
# generate nothing since there was no delivered quantity at this time.
account_move = self.env['account.move'].search(self.wizard.create_entries()['domain'])
self.assertRecordValues(account_move.line_ids, [
# reverse move lines
{'account_id': self.account_revenue.id, 'debit': 5000, 'credit': 0},
{'account_id': self.alt_inc_account.id, 'debit': 1000, 'credit': 0},
@ -99,9 +102,9 @@ class TestAccruedSaleOrders(AccountTestInvoicingCommon):
])
# delivered products invoiced, nothing to invoice left
self.sale_order.with_context(default_invoice_date=self.wizard.date)._create_invoices().action_post()
invoices = self.sale_order._create_invoices()
invoices.action_post()
with self.assertRaises(UserError):
self.env['account.move.line']._invalidate_cache()
self.wizard.create_entries()
self.assertTrue(self.wizard.display_amount)
@ -110,7 +113,7 @@ class TestAccruedSaleOrders(AccountTestInvoicingCommon):
self.sale_order.order_line.qty_delivered = 5
# self.sale_order.order_line.product_uom_qty = 5
# set currency != company currency
self.sale_order.currency_id = self.currency_data['currency']
self.sale_order.currency_id = self.other_currency
self.assertRecordValues(self.env['account.move'].search(self.wizard.create_entries()['domain']).line_ids, [
# reverse move lines
{'account_id': self.account_revenue.id, 'debit': 5000 / 2, 'credit': 0, 'amount_currency': 5000},
@ -127,12 +130,42 @@ class TestAccruedSaleOrders(AccountTestInvoicingCommon):
self.assertRecordValues(self.env['account.move'].search(self.wizard.create_entries()['domain']).line_ids, [
# reverse move lines
{'account_id': self.account_revenue.id, 'debit': 10000.0, 'credit': 0.0, 'analytic_distribution': {str(self.analytic_account_a.id): 80.0, str(self.analytic_account_b.id): 20.0, str(self.analytic_account_c.id): 100.0}},
{'account_id': self.alt_inc_account.id, 'debit': 2000.0, 'credit': 0.0, 'analytic_distribution': {str(self.analytic_account_b.id): 100.0, str(self.analytic_account_c.id): 100.0}},
{'account_id': self.account_expense.id, 'debit': 0.0, 'credit': 12000.0, 'analytic_distribution': {str(self.analytic_account_a.id): 66.67, str(self.analytic_account_b.id): 33.33, str(self.analytic_account_c.id): 100.0}},
{'account_id': self.account_revenue.id, 'debit': 10000.0, 'credit': 0.0, 'analytic_distribution': {str(self.analytic_account_a.id): 80.0, str(self.analytic_account_b.id): 20.0}},
{'account_id': self.alt_inc_account.id, 'debit': 2000.0, 'credit': 0.0, 'analytic_distribution': {str(self.analytic_account_b.id): 100.0}},
{'account_id': self.account_expense.id, 'debit': 0.0, 'credit': 12000.0, 'analytic_distribution': {str(self.analytic_account_a.id): 66.67, str(self.analytic_account_b.id): 33.33}},
# move lines
{'account_id': self.account_revenue.id, 'debit': 0.0, 'credit': 10000.0, 'analytic_distribution': {str(self.analytic_account_a.id): 80.0, str(self.analytic_account_b.id): 20.0, str(self.analytic_account_c.id): 100.0}},
{'account_id': self.alt_inc_account.id, 'debit': 0.0, 'credit': 2000.0, 'analytic_distribution': {str(self.analytic_account_b.id): 100.0, str(self.analytic_account_c.id): 100.0}},
{'account_id': self.account_expense.id, 'debit': 12000.0, 'credit': 0.0, 'analytic_distribution': {str(self.analytic_account_a.id): 66.67, str(self.analytic_account_b.id): 33.33, str(self.analytic_account_c.id): 100.0}},
{'account_id': self.account_revenue.id, 'debit': 0.0, 'credit': 10000.0, 'analytic_distribution': {str(self.analytic_account_a.id): 80.0, str(self.analytic_account_b.id): 20.0}},
{'account_id': self.alt_inc_account.id, 'debit': 0.0, 'credit': 2000.0, 'analytic_distribution': {str(self.analytic_account_b.id): 100.0}},
{'account_id': self.account_expense.id, 'debit': 12000.0, 'credit': 0.0, 'analytic_distribution': {str(self.analytic_account_a.id): 66.67, str(self.analytic_account_b.id): 33.33}},
])
def test_product_name_in_accrued_revenue_entry(self):
self.sale_order.order_line.qty_delivered = 5
so_context = {
'active_model': 'sale.order',
'active_ids': self.sale_order.ids,
'active_id': self.sale_order.id,
'default_journal_id': self.company_data['default_journal_sale'].id,
}
payment_params = {
'advance_payment_method': 'percentage',
'amount': 50.0,
}
downpayment = self.env['sale.advance.payment.inv'].with_context(so_context).create(payment_params)
invoice = downpayment._create_invoices(self.sale_order)
invoice.invoice_date = self.wizard.date
invoice.action_post()
self.wizard.create_entries()
self.assertFalse(self.wizard.display_amount)
self.assertRecordValues(self.env['account.move'].search(self.wizard.create_entries()['domain']).line_ids, [
# reverse move lines
{'account_id': self.account_revenue.id, 'debit': 5000, 'credit': 0},
{'account_id': self.alt_inc_account.id, 'debit': 1000, 'credit': 0},
{'account_id': self.wizard.account_id.id, 'debit': 0, 'credit': 6000},
# move lines
{'account_id': self.account_revenue.id, 'debit': 0, 'credit': 5000},
{'account_id': self.alt_inc_account.id, 'debit': 0, 'credit': 1000},
{'account_id': self.wizard.account_id.id, 'debit': 6000, 'credit': 0},
])

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
@ -17,7 +16,7 @@ class TestSaleCommon(SaleCommon):
self.assertEqual(self.empty_order.partner_invoice_id, self.partner)
self.assertEqual(self.empty_order.partner_shipping_id, self.partner)
self.assertEqual(self.empty_order.pricelist_id, self.pricelist)
self.assertEqual(self.empty_order.currency_id.name, 'USD')
self.assertEqual(self.empty_order.currency_id.name, self.currency.name)
self.assertEqual(self.empty_order.team_id, self.sale_team)
self.assertEqual(self.empty_order.state, 'draft')
@ -25,7 +24,7 @@ class TestSaleCommon(SaleCommon):
self.assertEqual(self.sale_order.partner_invoice_id, self.partner)
self.assertEqual(self.sale_order.partner_shipping_id, self.partner)
self.assertEqual(self.sale_order.pricelist_id, self.pricelist)
self.assertEqual(self.sale_order.currency_id.name, 'USD')
self.assertEqual(self.sale_order.currency_id.name, self.currency.name)
self.assertEqual(self.sale_order.team_id, self.sale_team)
self.assertEqual(self.sale_order.state, 'draft')
@ -33,16 +32,14 @@ class TestSaleCommon(SaleCommon):
self.assertFalse(consumable_line.pricelist_item_id)
self.assertEqual(consumable_line.price_unit, 20.0)
self.assertEqual(consumable_line.price_reduce, 20.0)
self.assertFalse(consumable_line.discount)
self.assertEqual(consumable_line.product_uom, self.uom_unit)
self.assertEqual(consumable_line.product_uom_id, self.uom_unit)
self.assertEqual(consumable_line.price_total, 5.0 * 20.0)
self.assertFalse(service_line.pricelist_item_id)
self.assertEqual(service_line.price_unit, 50.0)
self.assertEqual(service_line.price_reduce, 50.0)
self.assertFalse(service_line.discount)
self.assertEqual(service_line.product_uom, self.uom_unit)
self.assertEqual(service_line.product_uom_id, self.uom_unit)
self.assertEqual(service_line.price_total, 12.5 * 50)
self.assertEqual(self.sale_order.amount_total, 725.0)

View file

@ -1,20 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import HttpCase, tagged
from odoo.tools import mute_logger
from odoo.addons.base.tests.common import BaseUsersCommon, HttpCaseWithUserPortal
from odoo.addons.base.tests.common import HttpCaseWithUserPortal
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestAccessRightsControllers(BaseUsersCommon, HttpCase, SaleCommon):
class TestAccessRightsControllers(HttpCase, SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_portal = cls._create_new_portal_user()
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
def test_access_controller(self):
private_so = self.sale_order
portal_so = self.sale_order.copy()
portal_so.message_subscribe(self.user_portal.partner_id.ids)
# set portal as customer to give portal access
portal_so.partner_id = self.user_portal.partner_id.id
portal_so._portal_ensure_token()
token = portal_so.access_token
@ -66,7 +73,14 @@ class TestAccessRightsControllers(BaseUsersCommon, HttpCase, SaleCommon):
@tagged('post_install', '-at_install')
class TestSalesControllers(BaseUsersCommon, HttpCase, SaleCommon):
class TestSalesControllers(HttpCase, SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_portal = cls._create_new_portal_user()
def test_sales_portal_report(self):
portal_so = self.sale_order.copy()
portal_so.message_subscribe(self.user_portal.partner_id.ids)
@ -75,14 +89,14 @@ class TestSalesControllers(BaseUsersCommon, HttpCase, SaleCommon):
req = self.url_open(portal_so.get_portal_url(report_type='pdf'), allow_redirects=False)
self.assertEqual(req.status_code, 200)
self.assertEqual(req.headers['content-disposition'], f"inline; filename*=UTF-8''Quotation-{portal_so.name}.pdf")
self.assertEqual(req.headers['content-disposition'], f"inline; filename*=UTF-8''Quotation_{portal_so.name}.pdf")
req = self.url_open(portal_so.get_portal_url(report_type='pdf', download=True), allow_redirects=False)
self.assertEqual(req.status_code, 200)
self.assertEqual(req.headers['content-disposition'], f"attachment; filename*=UTF-8''Quotation-{portal_so.name}.pdf")
self.assertEqual(req.headers['content-disposition'], f"attachment; filename*=UTF-8''Quotation_{portal_so.name}.pdf")
@tagged('post_install', '-at_install')
@tagged('post_install', '-at_install', 'mail_flow')
class TestSaleSignature(HttpCaseWithUserPortal):
def test_01_portal_sale_signature_tour(self):
@ -100,11 +114,17 @@ class TestSaleSignature(HttpCaseWithUserPortal):
'order_id': sales_order.id,
'product_id': self.env['product.product'].create({'name': 'A product'}).id,
})
self.assertFalse(sales_order.message_partner_ids)
# 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'))
sales_order.with_context(**email_ctx).message_post_with_source(
self.env['mail.template'].browse(email_ctx.get('default_template_id')),
subtype_xmlid='mail.mt_comment',
)
self.assertFalse(
sales_order.message_partner_ids,
'Do not automatically set customer as follower, will be suggested recipient')
self.start_tour("/", 'sale_signature', login="portal")

View file

@ -1,9 +1,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo import Command
from odoo.exceptions import AccessError
from odoo.tests import Form, tagged, users
from .common import TestSaleCommon
from odoo.addons.sale.tests.common import TestSaleCommon
@tagged('post_install', '-at_install')
@ -31,6 +32,8 @@ class TestSaleOrderCreditLimit(TestSaleCommon):
'currency_id': buck_currency.id,
})
cls.company_data_2 = cls.setup_other_company()
cls.sales_user = cls.company_data['default_user_salesman']
cls.sales_user.write({
'login': "notaccountman",
@ -41,9 +44,114 @@ class TestSaleOrderCreditLimit(TestSaleCommon):
'partner_id': cls.partner_a.id,
})
def test_credit_limit_multi_company(self):
# multi-company setup
company2 = self.company_data_2['company']
# Activate the Credit Limit feature
company2.account_use_credit_limit = True
# Create and confirm a SO for that company
sale_order = company2.env['sale.order'].create({
'company_id': company2.id,
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'order_line': [Command.create({
'product_id': self.company_data_2['product_order_no'].id,
'price_unit': 1000.0,
})],
})
self.assertEqual(self.partner_a.with_company(company2).credit_to_invoice, 0.0)
sale_order.action_confirm()
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertEqual(self.partner_a.with_company(company2).credit_to_invoice, 1000.0)
partner_a_multi_company = self.partner_a.with_context(allowed_company_ids=[self.env.company.id, company2.id])
self.assertEqual(partner_a_multi_company.credit_to_invoice, 0.0)
self.assertEqual(self.partner_a.credit_to_invoice, 0.0)
def test_warning_on_invoice_with_downpayment(self):
# Activate the Credit Limit feature and set a value for partner_a.
self.env.company.account_use_credit_limit = True
self.partner_a.credit_limit = 1000.0
# Create and confirm a SO to reach (but not exceed) partner_a's credit limit.
sale_order = self.empty_order
sale_order.write({
'order_line': [Command.create({
'name': self.company_data['product_order_no'].name,
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 1,
'price_unit': 1000.0,
'tax_ids': False,
})]
})
# Check that partner_a's credit and credit_to_invoice is 0.0.
self.assertEqual(self.partner_a.credit, 0.0)
self.assertEqual(self.partner_a.credit_to_invoice, 0.0)
# Make sure partner_a's credit_to_invoice includes the newly confirmed SO.
sale_order.action_confirm()
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertEqual(self.partner_a.credit, 0.0)
self.assertEqual(self.partner_a.credit_to_invoice, 1000.0)
# Create a 50% down payment invoice.
self.env['sale.advance.payment.inv'].with_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,
}).create({
'advance_payment_method': 'percentage',
'amount': 50,
}).create_invoices()
invoice = sale_order.invoice_ids
# Check that the warning does not appear even though we are creating an invoice
# that should bring partner_a's credit above its limit.
self.assertEqual(invoice.partner_credit_warning, '')
# Make the down payment invoice amount larger than the Amount to Invoice
# and check that the warning appears with the correct amounts,
# i.e. 1.500 instead of 2.500 (1.000 SO + 1.500 down payment invoice).
invoice.invoice_line_ids.quantity = 3
self.assertEqual(
invoice.partner_credit_warning,
"partner_a has reached its credit limit of: $\xa01,000.00\n"
"Total amount due (including this document): $\xa01,500.00"
)
invoice.invoice_line_ids.quantity = 1
invoice.action_post()
# Create a credit note reversing the invoice
self.env['account.move.reversal'].with_company(self.env.company).with_context(
active_model="account.move",
active_ids=invoice.ids,
).create({
'journal_id': invoice.journal_id.id,
}).reverse_moves()
credit_note = sale_order.invoice_ids[1]
credit_note.action_post()
# Check that the credit note is accounted for correctly for the amount_to_invoice
self.assertEqual(sale_order.amount_to_invoice, sale_order.amount_total)
def test_credit_limit_multicurrency(self):
self.partner_a.credit_limit = 50
self.assertRecordValues(self.partner_a, [{
'credit': 0.0,
'credit_to_invoice': 0.0,
}])
order = self.empty_order
order.write({
'pricelist_id': self.buck_pricelist.id,
@ -52,7 +160,7 @@ class TestSaleOrderCreditLimit(TestSaleCommon):
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 1,
'price_unit': 45.0,
'tax_id': False,
'tax_ids': False,
})
]
})
@ -65,37 +173,221 @@ class TestSaleOrderCreditLimit(TestSaleCommon):
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 1,
'price_unit': 65.0,
'tax_id': False,
'tax_ids': False,
})
],
})
self.assertEqual(order.amount_total / order.currency_rate, 55)
self.assertEqual(
order.partner_credit_warning,
"partner_a has reached its Credit Limit of : $\xa050.00\n"
"Total amount due (including this document) : $\xa055.00"
"partner_a has reached its credit limit of: $\xa050.00\n"
"Total amount due (including this document): $\xa055.00"
)
# Make sure partner_a's credit_to_invoice includes the newly confirmed SO in the correct currency
order.action_confirm()
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertRecordValues(self.partner_a, [{
'credit': 0.0,
'credit_to_invoice': 55.0,
}])
# Make sure the invoice amount is converted correctly for the warning
invoice = order._create_invoices(final=True)
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertEqual(
invoice.partner_credit_warning,
"partner_a has reached its credit limit of: $\xa050.00\n"
"Total amount due (including this document): $\xa055.00"
)
# Make sure the invoice amount is converted correctly for the partner.credit computation
invoice.action_post()
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertRecordValues(self.partner_a, [{
'credit': 55.0,
'credit_to_invoice': 0.0,
}])
def test_invoice_independent_of_credit_to_invoice(self):
# Activate the Credit Limit feature and set a value for partner_a.
self.env.company.account_use_credit_limit = True
self.partner_a.credit_limit = 1000.0
# Create and confirm a SO to reach (but not exceed) partner_a's credit limit.
sale_order = self.empty_order
sale_order.write({
'order_line': [Command.create({
'product_id': self.company_data['product_order_no'].id,
'price_unit': 1000.0,
})]
})
# Check that partner_a's credit and credit_to_invoice is 0.0.
self.assertRecordValues(self.partner_a, [{
'credit': 0.0,
'credit_to_invoice': 0.0,
}])
# Make sure partner_a's credit_to_invoice includes the newly confirmed SO.
sale_order.action_confirm()
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertRecordValues(self.partner_a, [{
'credit': 0.0,
'credit_to_invoice': 1000.0,
}])
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [(0, 0, {
'name': 'test line',
'quantity': 1,
'price_unit': 100.0, # <= 1000 (sales order amount_total)
'tax_ids': False,
})],
})
self.assertRecordValues(self.partner_a, [{
'credit': 0.0,
'credit_to_invoice': 1000.0,
}])
self.assertEqual(
invoice.partner_credit_warning,
"partner_a has reached its credit limit of: $\xa01,000.00\n"
"Total amount due (including sales orders and this document): $\xa01,100.00"
)
invoice.invoice_line_ids[0].price_unit = 2000 # > 1000 (sales order amount_total)
self.assertEqual(
invoice.partner_credit_warning,
"partner_a has reached its credit limit of: $\xa01,000.00\n"
"Total amount due (including sales orders and this document): $\xa03,000.00"
)
invoice.action_post()
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertRecordValues(self.partner_a, [{
'credit': 2000.0,
'credit_to_invoice': 1000.0,
}])
def test_credit_limit_and_warning_overinvoiced_sales_order(self):
# Activate the Credit Limit feature and set a value for partner_a.
self.env.company.account_use_credit_limit = True
self.partner_a.credit_limit = 1000.0
# Create 2 SOs
self.empty_order.write({
'order_line': [Command.create({
'product_id': self.company_data['product_order_no'].id,
'price_unit': 1000.0,
})]
})
sale_orders = self.empty_order + self.empty_order.copy()
# Check that partner_a's credit and credit_to_invoice is 0.0.
self.assertRecordValues(self.partner_a, [{
'credit': 0.0,
'credit_to_invoice': 0.0,
}])
for order in sale_orders:
order.action_confirm()
# Make sure partner_a's credit_to_invoice includes the newly confirmed SOs.
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertRecordValues(self.partner_a, [{
'credit': 0.0,
'credit_to_invoice': 2000.0,
}])
# Invoice 1 of the SOs.
sale_order = sale_orders[0]
self.assertEqual(sale_order.amount_to_invoice, 1000.0)
invoice = sale_order._create_invoices(final=True)
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertEqual(invoice.amount_total, 1000.0)
# Modify the amount of the invoice to be greater than the amount of the (single) SO.
invoice.invoice_line_ids[0].price_unit = 2000.0
# Confirming the invoice will reduce the credit_to_invoice by 1000.
# This is since the amount of the sales order it originates from is 1000 and
# the amount of the invoice is more than 1000.
self.assertEqual(
invoice.partner_credit_warning,
"partner_a has reached its credit limit of: $\xa01,000.00\n"
"Total amount due (including sales orders and this document): $\xa03,000.00"
)
# Check that confirming the invoice changes the credit amounts as described above.
invoice.action_post()
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
self.assertRecordValues(self.partner_a, [{
'credit': 2000.0,
'credit_to_invoice': 1000.0,
}])
@users('notaccountman')
def test_credit_limit_access(self):
"""Ensure credit warning gets displayed without Accounting access."""
self.empty_order.user_id = self.env.user
self.empty_order.partner_id.credit_limit = self.product_a.list_price
self.empty_order.user_id = self.env.user # it's our order now
order = self.empty_order.with_env(self.env)
for group in self.partner_a._fields['credit'].groups.split(','):
self.assertFalse(self.env.user.has_group(group))
# Ensure we don't have access to accounting fields
with self.assertRaises(AccessError, msg="We shouldn't have access to credit"):
order.partner_id.credit_limit = 1e12
order.sudo().partner_id.credit_limit = self.product_a.list_price
with Form(self.empty_order.with_env(self.env)) as order_form:
with Form(order) as order_form:
with order_form.order_line.new() as sol:
sol.product_id = self.product_a
sol.tax_id.clear()
sol.tax_ids.clear()
self.assertFalse(
order_form.partner_credit_warning,
"No credit warning should be displayed (yet)",
)
with order_form.order_line.edit(0) as sol:
sol.tax_id.add(self.product_a.taxes_id)
sol.tax_ids.add(self.tax_sale_a)
self.assertTrue(
order_form.partner_credit_warning,
"Credit warning should be displayed",
)
def test_commercial_partner_credit(self):
"""Ensure that credit to invoice gets computed on partners' companies."""
company_a = self.env['res.partner'].create({
'name': "Company A",
'is_company': True,
'credit_limit': 10000.0,
'child_ids': [
Command.link(self.partner_a.id),
Command.create({
'name': "Company A Invoice",
'type': 'invoice',
}),
],
})
invoice_partner = company_a.child_ids.filtered(lambda p: p.type == 'invoice')
order = self.empty_order
order.order_line = [Command.create({
'product_id': self.company_data['product_order_no'].id,
'price_unit': 600.0,
'tax_ids': False,
})]
orders = order + order.copy({'partner_invoice_id': invoice_partner.id})
orders.action_confirm()
self.assertFalse(
self.partner_a.credit_to_invoice,
"Credit should only apply to the commercial entity",
)
self.assertFalse(company_a.credit)
self.assertEqual(company_a.credit_to_invoice, 1200.0)
invoices = orders._create_invoices()
invoices.action_post()
company_a.invalidate_recordset()
self.assertFalse(company_a.credit_to_invoice)
self.assertEqual(company_a.credit, 1200.0)

View file

@ -0,0 +1,61 @@
import unittest
from odoo.tests import TransactionCase, can_import, loaded_demo_data, tagged
from odoo.tools.misc import file_open
@tagged("post_install", "-at_install")
class TestImportFiles(TransactionCase):
@unittest.skipUnless(
can_import("xlrd.xlsx") or can_import("openpyxl"), "XLRD/XLSX not available",
)
def test_import_quotation_template_xls(self):
if not loaded_demo_data(self.env):
self.skipTest('Needs demo data to be able to import those files')
model = "sale.order"
filename = "quotations_import_template.xlsx"
file_content = file_open(f"sale/static/xls/{filename}", "rb").read()
import_wizard = self.env["base_import.import"].create(
{
"res_model": model,
"file": file_content,
"file_type": "application/vnd.ms-excel",
},
)
result = import_wizard.parse_preview(
{
"has_headers": True,
},
)
self.assertIsNone(result.get("error"))
field_names = ['/'.join(v) for v in result["matches"].values()]
results = import_wizard.execute_import(
field_names,
[r.lower() for r in result["headers"]],
{
"import_skip_records": [],
"import_set_empty_fields": [],
"fallback_values": {},
"name_create_enabled_fields": {},
"encoding": "",
"separator": "",
"quoting": '"',
"date_format": "",
"datetime_format": "",
"float_thousand_separator": ",",
"float_decimal_separator": ".",
"advanced": True,
"has_headers": True,
"keep_matches": False,
"limit": 2000,
"skip": 0,
"tracking_disable": True,
},
)
self.assertFalse(
results["messages"],
"results should be empty on successful import of ",
)

View file

@ -0,0 +1,83 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import patch
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestIrConfigParameter(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.IrConfigParameter = cls.env['ir.config_parameter']
# Create a test cron with XMLID.
cls.test_cron = cls.env['ir.cron'].create({
'name': "Test Cron",
'model_id': cls.env.ref('sale.model_sale_order').id, # whatever
'state': 'code',
'code': '',
'user_id': cls.env.uid,
'interval_number': 1,
'interval_type': 'days',
'active': False,
})
cls.env['ir.model.data'].create({
'name': 'test_cron',
'model': 'ir.cron',
'module': 'sale',
'res_id': cls.test_cron.id,
})
# Patch `_get_param_cron_mapping` to include the test records.
original_get_param_cron_mapping = cls.IrConfigParameter._get_param_cron_mapping
def patched_get_param_cron_mapping(_self):
mapping = original_get_param_cron_mapping()
mapping['sale.test_param'] = 'sale.test_cron'
return mapping
_get_param_cron_mapping_patcher = patch(
'odoo.addons.sale.models.ir_config_parameter.IrConfigParameter._get_param_cron_mapping',
new=patched_get_param_cron_mapping,
)
cls.startClassPatcher(_get_param_cron_mapping_patcher)
def test_creating_enabled_param_activates_cron(self):
"""Test cron synchronization when creating an enabled config parameter."""
self.assertFalse(self.test_cron.active)
self.IrConfigParameter.create({'key': 'sale.test_param', 'value': 'True'})
self.assertTrue(self.test_cron.active)
def test_creating_disabled_param_disables_cron(self):
"""Test cron synchronization when creating a disabled config parameter."""
self.test_cron.active = True
self.IrConfigParameter.create({'key': 'sale.test_param', 'value': 'False'})
self.assertFalse(self.test_cron.active)
def test_setting_enabled_param_value_activates_cron(self):
"""Test cron synchronization when updating to an enabled config parameter."""
param = self.IrConfigParameter.create({'key': 'sale.test_param', 'value': 'False'})
param.value = 'True'
self.assertTrue(self.test_cron.active)
def test_setting_disabled_param_value_disables_cron(self):
"""Test cron synchronization when updating to a disabled config parameter."""
param = self.IrConfigParameter.create({'key': 'sale.test_param', 'value': 'True'})
param.value = 'False'
self.assertFalse(self.test_cron.active)
def test_deleting_param_disables_cron(self):
"""Test cron synchronization when deleting a config parameter."""
param = self.IrConfigParameter.create({'key': 'sale.test_param', 'value': 'True'})
param.unlink()
self.assertFalse(self.test_cron.active)
def test_non_mapped_param_has_no_effect_on_cron(self):
"""Test that non-mapped parameter don't affect crons."""
self.IrConfigParameter.create({'key': 'sale.non_mapped_param', 'value': 'True'})
self.assertFalse(self.test_cron.active)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase, tagged
@ -7,68 +6,6 @@ from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSaleOnchanges(TransactionCase):
def test_sale_warnings(self):
"""Test warnings & SO/SOL updates when partner/products with sale warnings are used."""
partner_with_warning = self.env['res.partner'].create({
'name': 'Test', 'sale_warn': 'warning', 'sale_warn_msg': 'Highly infectious disease'})
partner_with_block_warning = self.env['res.partner'].create({
'name': 'Test2', 'sale_warn': 'block', 'sale_warn_msg': 'Cannot afford our services'})
sale_order = self.env['sale.order'].create({'partner_id': partner_with_warning.id})
warning = sale_order._onchange_partner_id_warning()
self.assertDictEqual(warning, {
'warning': {
'title': "Warning for Test",
'message': partner_with_warning.sale_warn_msg,
},
})
sale_order.partner_id = partner_with_block_warning
warning = sale_order._onchange_partner_id_warning()
self.assertDictEqual(warning, {
'warning': {
'title': "Warning for Test2",
'message': partner_with_block_warning.sale_warn_msg,
},
})
# Verify partner-related fields have been correctly reset
self.assertFalse(sale_order.partner_id.id)
self.assertFalse(sale_order.partner_invoice_id.id)
self.assertFalse(sale_order.partner_shipping_id.id)
self.assertFalse(sale_order.pricelist_id.id)
# Reuse non blocking partner for product warning tests
sale_order.partner_id = partner_with_warning
product_with_warning = self.env['product.product'].create({
'name': 'Test Product', 'sale_line_warn': 'warning', 'sale_line_warn_msg': 'Highly corrosive'})
product_with_block_warning = self.env['product.product'].create({
'name': 'Test Product (2)', 'sale_line_warn': 'block', 'sale_line_warn_msg': 'Not produced anymore'})
sale_order_line = self.env['sale.order.line'].create({
'order_id': sale_order.id,
'product_id': product_with_warning.id,
})
warning = sale_order_line._onchange_product_id_warning()
self.assertDictEqual(warning, {
'warning': {
'title': "Warning for Test Product",
'message': product_with_warning.sale_line_warn_msg,
},
})
sale_order_line.product_id = product_with_block_warning
warning = sale_order_line._onchange_product_id_warning()
self.assertDictEqual(warning, {
'warning': {
'title': "Warning for Test Product (2)",
'message': product_with_block_warning.sale_line_warn_msg,
},
})
self.assertFalse(sale_order_line.product_id.id)
def test_create_products_in_different_companies(self):
""" Ensures the product's constrain on `company_id` doesn't block the creation of multiple
products in different companies (see `product.template` `_check_sale_product_company`.)

View file

@ -1,17 +1,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import ANY, patch
from odoo.fields import Command
from odoo.tests import tagged
from unittest.mock import patch
from odoo.tests import JsonRpcException, tagged
from odoo.tools import mute_logger
from odoo.addons.account_payment.tests.common import AccountPaymentCommon
from odoo.addons.http_routing.tests.common import MockRequest
from odoo.addons.mail.tests.common import MailCase
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
from odoo.addons.sale.controllers.portal import CustomerPortal
from odoo.addons.sale.tests.common import SaleCommon
@tagged('-at_install', 'post_install')
class TestSalePayment(AccountPaymentCommon, SaleCommon, PaymentHttpCommon):
class TestSalePayment(AccountPaymentCommon, MailCase, PaymentHttpCommon, SaleCommon):
@classmethod
def setUpClass(cls):
@ -21,174 +24,115 @@ class TestSalePayment(AccountPaymentCommon, SaleCommon, PaymentHttpCommon):
cls.currency = cls.sale_order.currency_id
cls.partner = cls.sale_order.partner_invoice_id
def test_11_so_payment_link(self):
# test customized /payment/pay route with sale_order_id param
self.amount = self.sale_order.amount_total
route_values = self._prepare_pay_values()
route_values['sale_order_id'] = self.sale_order.id
cls.provider.journal_id.inbound_payment_method_line_ids.filtered(
lambda l: l.payment_provider_id == cls.provider
).payment_account_id = cls.inbound_payment_method_line.payment_account_id
with patch(
'odoo.addons.payment.controllers.portal.PaymentPortal'
'._compute_show_tokenize_input_mapping'
) as patched:
tx_context = self._get_tx_checkout_context(**route_values)
patched.assert_called_once_with(ANY, logged_in=ANY, sale_order_id=ANY)
cls.sale_order.require_payment = True
self.assertEqual(tx_context['currency_id'], self.sale_order.currency_id.id)
self.assertEqual(tx_context['partner_id'], self.sale_order.partner_invoice_id.id)
self.assertEqual(tx_context['amount'], self.sale_order.amount_total)
self.assertEqual(tx_context['sale_order_id'], self.sale_order.id)
route_values.update({
'flow': 'direct',
'payment_option_id': self.provider.id,
'tokenization_requested': False,
'validation_route': False,
'reference_prefix': None, # Force empty prefix to fallback on SO reference
'landing_route': tx_context['landing_route'],
'amount': tx_context['amount'],
'currency_id': tx_context['currency_id'],
@mute_logger('odoo.http', 'werkzeug')
def test_payment_amount_must_not_be_less_than_prepayment_amount(self):
""" Test that accessing the portal page with a payment amount below prepayment amount raises
an error. """
res = self._make_http_get_request(f'/my/orders/{self.sale_order.id}', params={
'access_token': self.sale_order._portal_ensure_token(), 'payment_amount': 1
})
self.assertEqual(res.status_code, 404)
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self._get_processing_values(**route_values)
tx_sudo = self._get_tx(processing_values['reference'])
def test_is_down_payment_when_prepayment_amount_is_less_than_order_total(self):
"""Test that we are in the downpayment case when the prepayment amount is less than the
order total."""
self.sale_order.prepayment_percent = 0.5
self.assertTrue(CustomerPortal()._determine_is_down_payment(
self.sale_order, 'whatever', None
))
self.assertEqual(tx_sudo.sale_order_ids, self.sale_order)
self.assertEqual(tx_sudo.amount, self.amount)
self.assertEqual(tx_sudo.partner_id, self.sale_order.partner_invoice_id)
self.assertEqual(tx_sudo.company_id, self.sale_order.company_id)
self.assertEqual(tx_sudo.currency_id, self.sale_order.currency_id)
self.assertEqual(tx_sudo.reference, self.sale_order.name)
def test_is_not_down_payment_when_prepayment_amount_equals_order_total(self):
"""Test that we are not in the downpayment case when the prepayment amount equals the order
total."""
self.sale_order.prepayment_percent = 1.0
self.assertFalse(CustomerPortal()._determine_is_down_payment(
self.sale_order, 'whatever', None
))
# Check validation of transaction correctly confirms the SO
self.assertEqual(self.sale_order.state, 'draft')
self.assertEqual(tx_sudo.sale_order_ids.transaction_ids, tx_sudo)
tx_sudo._set_done()
tx_sudo._finalize_post_processing()
self.assertEqual(self.sale_order.state, 'sale')
self.assertTrue(tx_sudo.payment_id)
self.assertEqual(tx_sudo.payment_id.state, 'posted')
def test_is_down_payment_when_link_amount_is_less_than_order_total(self):
"""Test that we are in the downpayment case when the link amount is less than the order
total."""
self.assertTrue(CustomerPortal()._determine_is_down_payment(
self.sale_order, 'whatever', self.sale_order.amount_total * 0.5
))
def test_so_payment_link_with_different_partner_invoice(self):
# test customized /payment/pay route with sale_order_id param
# partner_id and partner_invoice_id different on the so
self.sale_order.partner_invoice_id = self.portal_partner
self.partner = self.sale_order.partner_invoice_id
route_values = self._prepare_pay_values()
route_values['sale_order_id'] = self.sale_order.id
def test_is_not_down_payment_when_link_amount_equals_order_total(self):
"""Test that we are not in the downpayment case when the link amount equals the order total.
"""
self.assertFalse(CustomerPortal()._determine_is_down_payment(
self.sale_order, 'whatever', self.sale_order.amount_total
))
tx_context = self._get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['partner_id'], self.sale_order.partner_invoice_id.id)
def test_downpayment_amount_equals_link_amount_when_higher_than_prepayment_amount(self):
"""Test that the payment link's amount is used for the transaction when that amount is
higher than the prepayment amount and the user chose to pay a down payment."""
self.sale_order.prepayment_percent = 0.5 # This should be ignored when the link is higher.
link_amount = self.sale_order.amount_total * 0.7
with MockRequest(self.env):
tx_values = CustomerPortal()._get_payment_values(
self.sale_order, is_down_payment=True, payment_amount=link_amount
)
self.assertEqual(tx_values['amount'], link_amount)
def test_12_so_partial_payment_link(self):
# test customized /payment/pay route with sale_order_id param
# partial amount specified
self.amount = self.sale_order.amount_total / 2.0
route_values = self._prepare_pay_values()
route_values['sale_order_id'] = self.sale_order.id
def test_downpayment_amount_equals_prepayment_amount_when_less_than_order_total(self):
"""Test that the payment link's amount is used for the transaction when that amount is
higher than the prepayment amount and the user chose to pay a down payment."""
self.sale_order.prepayment_percent = 0.5
with MockRequest(self.env):
tx_values = CustomerPortal()._get_payment_values(
self.sale_order, is_down_payment=True, payment_amount=self.sale_order.amount_total
)
self.assertEqual(tx_values['amount'], self.sale_order._get_prepayment_required_amount())
tx_context = self._get_tx_checkout_context(**route_values)
def test_downpayment_amount_equals_prepayment_amount_when_no_link_amount(self):
"""Test that the prepayment amount is used for the transaction when no payment amount is
specified in the link and the user chose to pay a down payment."""
self.sale_order.prepayment_percent = 0.5
with MockRequest(self.env):
tx_values = CustomerPortal()._get_payment_values(
self.sale_order, is_down_payment=True, payment_amount=None
)
prepayment_amount = self.sale_order._get_prepayment_required_amount()
self.assertEqual(tx_values['amount'], prepayment_amount)
self.assertEqual(tx_context['reference_prefix'], self.reference)
self.assertEqual(tx_context['currency_id'], self.sale_order.currency_id.id)
self.assertEqual(tx_context['partner_id'], self.sale_order.partner_invoice_id.id)
self.assertEqual(tx_context['amount'], self.amount)
self.assertEqual(tx_context['sale_order_id'], self.sale_order.id)
def test_payment_amount_equals_link_amount_when_order_is_confirmed(self):
"""Test that the payment link's amount is used for the transaction when the order is
confirmed."""
self.sale_order.action_confirm()
payment_amount = self.sale_order.amount_total * 0.5
with MockRequest(self.env):
tx_values = CustomerPortal()._get_payment_values(
self.sale_order, is_down_payment=False, payment_amount=payment_amount
)
self.assertEqual(tx_values['amount'], payment_amount)
route_values.update({
'flow': 'direct',
'payment_option_id': self.provider.id,
'tokenization_requested': False,
'validation_route': False,
'reference_prefix': tx_context['reference_prefix'],
'landing_route': tx_context['landing_route'],
})
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self._get_processing_values(**route_values)
tx_sudo = self._get_tx(processing_values['reference'])
def test_payment_amount_equals_order_total_when_no_link_amount_and_order_is_confirmed(self):
"""Test that the order total is used for the transaction when no payment amount is specified
in the link and the order is confirmed."""
self.sale_order.action_confirm()
with MockRequest(self.env):
tx_values = CustomerPortal()._get_payment_values(
self.sale_order, is_down_payment=False, payment_amount=None
)
self.assertEqual(tx_values['amount'], self.sale_order.amount_total)
self.assertEqual(tx_sudo.sale_order_ids, self.sale_order)
self.assertEqual(tx_sudo.amount, self.amount)
self.assertEqual(tx_sudo.partner_id, self.sale_order.partner_invoice_id)
self.assertEqual(tx_sudo.company_id, self.sale_order.company_id)
self.assertEqual(tx_sudo.currency_id, self.sale_order.currency_id)
self.assertEqual(tx_sudo.reference, self.reference)
self.assertEqual(tx_sudo.sale_order_ids.transaction_ids, tx_sudo)
tx_sudo._set_done()
with mute_logger('odoo.addons.sale.models.payment_transaction'):
tx_sudo._finalize_post_processing()
self.assertEqual(self.sale_order.state, 'draft') # Only a partial amount was paid
# Pay the remaining amount
route_values = self._prepare_pay_values()
route_values['sale_order_id'] = self.sale_order.id
tx_context = self._get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['reference_prefix'], self.reference)
self.assertEqual(tx_context['currency_id'], self.sale_order.currency_id.id)
self.assertEqual(tx_context['partner_id'], self.sale_order.partner_invoice_id.id)
self.assertEqual(tx_context['amount'], self.amount)
self.assertEqual(tx_context['sale_order_id'], self.sale_order.id)
route_values.update({
'flow': 'direct',
'payment_option_id': self.provider.id,
'tokenization_requested': False,
'validation_route': False,
'reference_prefix': tx_context['reference_prefix'],
'landing_route': tx_context['landing_route'],
})
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self._get_processing_values(**route_values)
tx2_sudo = self._get_tx(processing_values['reference'])
self.assertEqual(tx2_sudo.sale_order_ids, self.sale_order)
self.assertEqual(tx2_sudo.amount, self.amount)
self.assertEqual(tx2_sudo.partner_id, self.sale_order.partner_invoice_id)
self.assertEqual(tx2_sudo.company_id, self.sale_order.company_id)
self.assertEqual(tx2_sudo.currency_id, self.sale_order.currency_id)
# We are paying a second time with the same reference (prefix)
# a suffix is added to respect unique reference constraint
reference = self.reference + "-1"
self.assertEqual(tx2_sudo.reference, reference)
self.assertEqual(self.sale_order.state, 'draft')
self.assertEqual(self.sale_order.transaction_ids, tx_sudo + tx2_sudo)
def test_13_sale_automatic_partial_payment_link_delivery(self):
"""Test that with automatic invoice and invoicing policy based on delivered quantity, a transaction for the partial
amount does not validate the SO."""
# set automatic invoice
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
# invoicing policy is based on delivered quantity
self.product.invoice_policy = 'delivery'
self.amount = self.sale_order.amount_total / 2.0
route_values = self._prepare_pay_values()
route_values['sale_order_id'] = self.sale_order.id
tx_context = self._get_tx_checkout_context(**route_values)
route_values.update({
'flow': 'direct',
'payment_option_id': self.provider.id,
'tokenization_requested': False,
'validation_route': False,
'reference_prefix': tx_context['reference_prefix'],
'landing_route': tx_context['landing_route'],
})
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self._get_processing_values(**route_values)
tx_sudo = self._get_tx(processing_values['reference'])
tx_sudo._set_done()
with mute_logger('odoo.addons.sale.models.payment_transaction'):
tx_sudo._finalize_post_processing()
self.assertEqual(self.sale_order.state, 'draft', 'a partial transaction with automatic invoice and invoice_policy = delivery should not validate a quote')
def test_full_amount_equals_order_total(self):
"""Test that the order total is used for the transaction when the user chose to pay the full
amount. """
self.sale_order.prepayment_percent = 0.5 # This should not impact the 'full amount' option.
with MockRequest(self.env):
tx_values = CustomerPortal()._get_payment_values(
self.sale_order,
is_down_payment=False,
payment_amount=self.sale_order._get_prepayment_required_amount()
)
self.assertEqual(tx_values['amount'], self.sale_order.amount_total)
def test_confirmed_transactions_comfirms_so_with_multiple_transaction(self):
""" Test that a confirmed transaction confirms a SO even if one or more non-confirmed
@ -208,37 +152,126 @@ class TestSalePayment(AccountPaymentCommon, SaleCommon, PaymentHttpCommon):
reference='Test Transaction Draft 2',
)
tx = self._create_transaction(flow='redirect', sale_order_ids=[self.sale_order.id], state='done')
tx._reconcile_after_done()
tx._post_process()
self.assertEqual(self.sale_order.state, 'sale')
def test_auto_confirm_and_auto_invoice(self):
"""
Assuming that the automatic invoice setting is activated, we expect
that after the payment is post processed:
- invoice created
- SO confirmed
- Two emails sent: SO confirmation and default invoice email template
"""
# Set automatic invoice
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
# Create the payment
self.amount = self.sale_order.amount_total
self.partner.email = 'customer@example.com' # make sure partner on SO has email set
tx = self._create_transaction(flow='redirect', sale_order_ids=[self.sale_order.id], state='done')
with mute_logger('odoo.addons.sale.models.payment_transaction'):
tx._reconcile_after_done()
with (
mute_logger('odoo.addons.sale.models.payment_transaction'),
self.mock_mail_gateway(),
):
tx._post_process()
self.assertEqual(self.sale_order.state, 'sale')
self.assertTrue(tx.invoice_ids)
self.assertTrue(self.sale_order.invoice_ids)
self.assertEqual(len(self._new_mails), 2)
self.assertTrue(self._new_mails.filtered(lambda x: 'Invoice' in x.subject))
def test_auto_confirm_and_auto_invoice_custom_mail_template(self):
"""
Assuming that the automatic invoice setting is activated and a custom
email template for invoicing was selected, we expect that after the
payment is post processed:
- invoice created
- SO confirmed
- Two emails sent: SO confirmation and invoice email using the custom template
"""
# Set automatic invoice
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
custom_template = self.env['mail.template'].create({
'name': 'Custom Test Invoice Template',
'model_id': self.env.ref('account.model_account_move').id,
'subject': 'Your Custom Template',
'partner_to': '{{ object.partner_id.id }}',
'email_from': '{{ (object.invoice_user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}',
})
self.env['ir.config_parameter'].set_param('sale.default_invoice_email_template', custom_template.id)
# Create the payment
self.amount = self.sale_order.amount_total
self.partner.email = 'customer@example.com' # make sure partner on SO has email set
tx = self._create_transaction(flow='redirect', sale_order_ids=[self.sale_order.id], state='done')
with (
mute_logger('odoo.addons.sale.models.payment_transaction'),
self.mock_mail_gateway(),
):
tx._post_process()
self.assertEqual(self.sale_order.state, 'sale')
self.assertTrue(tx.invoice_ids)
self.assertTrue(self.sale_order.invoice_ids)
self.assertEqual(len(self._new_mails), 2)
self.assertTrue(self._new_mails.filtered(lambda x: 'Your Custom Template' in x.subject))
def test_auto_confirm_and_auto_invoice_custom_mail_template_unlinked(self):
"""
Assuming that the automatic invoice setting is activated and a custom
email template for invoicing was selected. If the custom email template
gets unlinked, the system parameter still stores the id, but code
should fall back to default invoice email template. We expect that after the
payment is post processed:
- invoice created
- SO confirmed
- Two emails sent: SO confirmation and invoice email using the DEFAULT template
"""
# Set automatic invoice
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
custom_template = self.env['mail.template'].create({
'name': 'Custom Test Invoice Template',
'model_id': self.env.ref('account.model_account_move').id,
'subject': 'Your Custom Template',
'partner_to': '{{ object.partner_id.id }}',
'email_from': '{{ (object.invoice_user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}',
})
self.env['ir.config_parameter'].set_param('sale.default_invoice_email_template', custom_template.id)
custom_template.unlink()
# Create the payment
self.amount = self.sale_order.amount_total
self.partner.email = 'customer@example.com' # make sure partner on SO has email set
tx = self._create_transaction(flow='redirect', sale_order_ids=[self.sale_order.id], state='done')
with (
mute_logger('odoo.addons.sale.models.payment_transaction'),
self.mock_mail_gateway(),
):
tx._post_process()
self.assertEqual(self.sale_order.state, 'sale')
self.assertTrue(tx.invoice_ids)
self.assertTrue(self.sale_order.invoice_ids)
self.assertEqual(len(self._new_mails), 2)
self.assertTrue(self._new_mails.filtered(lambda x: 'Invoice' in x.subject))
def test_auto_done_and_auto_invoice(self):
# Set automatic invoice
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
# Lock the sale orders when confirmed
self.env.user.groups_id += self.env.ref('sale.group_auto_done_setting')
self.group_user.implied_ids += self.env.ref('sale.group_auto_done_setting')
# Create the payment
self.amount = self.sale_order.amount_total
tx = self._create_transaction(flow='redirect', sale_order_ids=[self.sale_order.id], state='done')
with mute_logger('odoo.addons.sale.models.payment_transaction'):
tx._reconcile_after_done()
tx._post_process()
self.assertEqual(self.sale_order.state, 'done')
self.assertEqual(self.sale_order.state, 'sale')
self.assertTrue(self.sale_order.locked)
self.assertTrue(tx.invoice_ids)
self.assertTrue(self.sale_order.invoice_ids)
self.assertTrue(tx.invoice_ids.is_move_sent)
@ -251,7 +284,7 @@ class TestSalePayment(AccountPaymentCommon, SaleCommon, PaymentHttpCommon):
self.amount = self.sale_order.amount_total / 10.
tx = self._create_transaction(flow='redirect', sale_order_ids=[self.sale_order.id], state='done')
with mute_logger('odoo.addons.sale.models.payment_transaction'):
tx._reconcile_after_done()
tx._post_process()
self.assertEqual(self.sale_order.state, 'draft')
self.assertFalse(tx.invoice_ids)
@ -267,7 +300,241 @@ class TestSalePayment(AccountPaymentCommon, SaleCommon, PaymentHttpCommon):
# Create the payment
self.amount = self.sale_order.amount_total
tx = self._create_transaction(flow='redirect', sale_order_ids=[self.sale_order.id], state='done')
tx._reconcile_after_done()
tx._post_process()
self.assertTrue(tx.invoice_ids)
self.assertTrue(self.sale_order.invoice_ids)
def test_invoice_is_final(self):
"""Test that invoice generated from a payment are always final"""
# Set automatic invoice
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
# Create the payment
self.amount = self.sale_order.amount_total
tx = self._create_transaction(
flow='redirect',
sale_order_ids=[self.sale_order.id],
state='done',
)
with mute_logger('odoo.addons.sale.models.payment_transaction'), patch(
'odoo.addons.sale.models.sale_order.SaleOrder._create_invoices',
return_value=self.env['account.move']
) as _create_invoices_mock:
tx._post_process()
self.assertTrue(_create_invoices_mock.call_args.kwargs['final'])
def test_linked_transactions_when_invoicing(self):
self.provider.support_manual_capture = 'partial'
partial_amount = self.sale_order.amount_total - 2
partial_tx_done = self._create_transaction(
flow='direct',
amount=partial_amount,
sale_order_ids=[self.sale_order.id],
state='done',
reference='partial_tx_done',
)
with mute_logger('odoo.addons.sale.models.payment_transaction'):
partial_tx_done._post_process()
partial_tx_pending = self._create_transaction(
flow='direct',
amount=2,
sale_order_ids=[self.sale_order.id],
state='pending',
reference='partial_tx_pending',
)
self.assertTrue(partial_tx_done.payment_id, msg="Account payment should have been created.")
msg = "The created account payment shouldn't be reconciled as there are no invoice yet."
self.assertFalse(partial_tx_pending.payment_id.is_reconciled, msg=msg)
# Add some noisy transactions
self._create_transaction(
flow='direct', sale_order_ids=[self.sale_order.id], state='draft', reference='draft_tx'
)
self._create_transaction(
flow='direct', sale_order_ids=[self.sale_order.id], state='error', reference='error_tx'
)
self._create_transaction(
flow='direct', sale_order_ids=[self.sale_order.id], state='cancel', reference='cncl_tx'
)
msg = "The sale order should be linked to 5 transactions."
self.assertEqual(len(self.sale_order.transaction_ids), 5, msg=msg)
self.sale_order.action_confirm()
self.sale_order._create_invoices()
self.assertEqual(len(self.sale_order.invoice_ids), 1, msg="1 invoice should be created.")
first_invoice = self.sale_order.invoice_ids
linked_txs = first_invoice.transaction_ids
msg = "The newly created invoice should be linked to the done and pending transactions."
self.assertEqual(len(linked_txs), 2, msg=msg)
expected_linked_tx = (partial_tx_done, partial_tx_pending)
self.assertTrue(all(tx in expected_linked_tx for tx in linked_txs), msg=msg)
msg = "The payment shouldn't be reconciled yet."
self.assertFalse(partial_tx_done.payment_id.is_reconciled, msg=msg)
partial_tx_done._post_process()
msg = "The payment should now be reconciled."
self.assertTrue(partial_tx_done.payment_id.is_reconciled, msg=msg)
self.sale_order.order_line[0].product_uom_qty += 2
self.sale_order._create_invoices()
second_invoice = self.sale_order.invoice_ids - first_invoice
msg = "The newly created invoice should only be linked to the pending transaction."
self.assertEqual(len(second_invoice.transaction_ids), 1, msg=msg)
self.assertEqual(second_invoice.transaction_ids.state, 'pending', msg=msg)
def test_downpayment_confirm_sale_order_sufficient_amount(self):
"""Paying down payments can confirm an order if amount is enough."""
self.sale_order.prepayment_percent = 0.1
order_amount = self.sale_order.amount_total
tx = self._create_transaction(
flow='direct',
amount=order_amount * self.sale_order.prepayment_percent,
sale_order_ids=[self.sale_order.id],
state='done',
)
with mute_logger('odoo.addons.sale.models.payment_transaction'):
tx._post_process()
self.assertTrue(self.sale_order.state == 'sale')
def test_downpayment_automatic_invoice(self):
"""
Down payment invoices should be created when a down payment confirms
the order and automatic invoice is checked.
"""
self.sale_order.prepayment_percent = 0.2
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
tx = self._create_transaction(
flow='direct',
amount=self.sale_order.amount_total * self.sale_order.prepayment_percent,
sale_order_ids=[self.sale_order.id],
state='done')
with mute_logger('odoo.addons.sale.models.payment_transaction'):
tx._post_process()
invoice = self.sale_order.invoice_ids
self.assertTrue(len(invoice) == 1)
self.assertTrue(invoice.line_ids[0].is_downpayment)
@mute_logger('odoo.http')
def test_transaction_route_rejects_unexpected_kwarg(self):
url = self._build_url(f'/my/orders/{self.sale_order.id}/transaction')
route_kwargs = {
'access_token': self.sale_order._portal_ensure_token(),
'partner_id': self.partner.id, # This should be rejected.
}
with self.assertRaises(JsonRpcException, msg='odoo.exceptions.ValidationError'):
self.make_jsonrpc_request(url, route_kwargs)
def test_partial_payment_confirm_order(self):
"""
Test that a sale order can be confirmed through partial payments and that
correct mails are sent each time.
"""
self.amount = self.sale_order.amount_total / 2
with patch(
'odoo.addons.sale.models.sale_order.SaleOrder._send_order_notification_mail',
) as notification_mail_mock:
tx_pending = self._create_transaction(
flow='direct',
sale_order_ids=[self.sale_order.id],
state='pending',
reference='Test Transaction Draft 1',
)
self.assertEqual(self.sale_order.state, 'draft')
tx_pending._set_done()
tx_pending._post_process()
self.assertEqual(notification_mail_mock.call_count, 1)
notification_mail_mock.assert_called_once_with(
self.env.ref('sale.mail_template_sale_payment_executed'))
self.assertEqual(self.sale_order.state, 'draft')
self.assertEqual(self.sale_order.amount_paid, self.amount)
tx_done = self._create_transaction(
flow='direct',
sale_order_ids=[self.sale_order.id],
state='done',
reference='Test Transaction Draft 2',
)
tx_done._post_process()
self.assertEqual(notification_mail_mock.call_count, 2)
order_confirmation_mail_template_id = int(
self.env["ir.config_parameter"]
.sudo()
.get_param("sale.default_confirmation_template", self.env.ref("sale.mail_template_sale_confirmation").id)
)
notification_mail_mock.assert_called_with(self.env["mail.template"].browse(order_confirmation_mail_template_id))
self.assertEqual(self.sale_order.state, 'sale')
def test_automatic_invoice_mail_author(self):
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
portal_user = self.env['res.users'].create({
'name': 'Portal Customer',
'login': 'portal@test.com',
'email': 'portal@test.com',
'partner_id': self.partner_a.id,
'group_ids': [(6, 0, [self.env.ref('base.group_portal').id])],
})
portal_user.partner_id.invoice_sending_method = 'email'
sale_order = self.env['sale.order'].with_user(portal_user).sudo().create({
'partner_id': portal_user.partner_id.id,
'user_id': self.sale_user.id,
'order_line': [(0, 0, {
'product_id': self.product_a.id,
'product_uom_qty': 1,
'price_unit': 100.0,
})],
})
self.amount = sale_order.amount_total
tx = self._create_transaction(
flow='redirect',
sale_order_ids=[sale_order.id],
state='done'
)
with mute_logger('odoo.addons.sale.models.payment_transaction'):
tx.with_user(portal_user).sudo()._post_process()
# Verify invoice was created and sent successfully
invoice = sale_order.invoice_ids[0]
self.assertTrue(invoice.is_move_sent, "Invoice should be marked as sent")
invoice_sent_mail = invoice.message_ids[0]
self.assertTrue(invoice_sent_mail.author_id.id not in invoice_sent_mail.notified_partner_ids.ids)
def test_refund_message_author_is_logged_in_user_for_sale_order(self):
"""Ensure that the chatter message author is the user processing the refund."""
self.provider.support_refund = 'full_only'
tx = self._create_transaction(
'redirect',
sale_order_ids=[self.sale_order.id],
state='done',
)
tx._post_process()
with patch.object(
self.env.registry['mail.thread'], 'message_post', autospec=True
) as message_post_mock:
tx.action_refund()
author_id = message_post_mock.call_args[1].get("author_id")
self.assertEqual(author_id, self.user.partner_id.id)

View file

@ -0,0 +1,73 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import HttpCase, tagged
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestProductAttributeValue(HttpCase, SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.product_attribute = cls.env['product.attribute'].create({
'name': 'PA',
'sequence': 1,
'create_variant': 'no_variant',
'value_ids': [
Command.create({'name': f'pa_value_{i + 1}', 'sequence': i})
for i in range(3)
]
})
cls.a1, cls.a2, cls.a3 = cls.product_attribute.value_ids
cls.product_template, cls.archived_template = cls.env['product.template'].create([
{
'name': 'P1',
'type': 'consu',
'attribute_line_ids': [Command.create({
'attribute_id': cls.product_attribute.id,
'value_ids': [Command.set([cls.a1.id, cls.a3.id])],
})],
},
{
'name': 'P2',
'type': 'consu',
'attribute_line_ids': [Command.create({
'attribute_id': cls.product_attribute.id,
'value_ids': [Command.set([cls.a1.id, cls.a2.id])],
})],
}
])
cls.archived_template.action_archive()
cls.empty_order.order_line = [
Command.create({
'product_id': cls.product_template.product_variant_id.id,
'product_no_variant_attribute_value_ids': [
Command.set(
cls.product_template.attribute_line_ids.product_template_value_ids.filtered(
lambda ptav: ptav.product_attribute_value_id.id == cls.a3.id
).ids,
),
],
}),
]
cls.order_line = cls.empty_order.order_line
def test_attribute_values_deletion_or_archiving(self):
"""Check that product attributes can be deleted if product or linked ptav are archived."""
if self.env['ir.module.module']._get('sale_management').state != 'installed':
self.skipTest("Sale App is not installed, Sale menu is not accessible.")
# Make sure variants are enabled (needed for menu access)
group_variant = self.env.ref('product.group_product_variant')
self.group_user.implied_ids = [Command.link(group_variant.id)]
self.product_template.attribute_line_ids.update({'value_ids': [Command.set([self.a1.id])]})
self.assertEqual(
self.order_line.product_no_variant_attribute_value_ids.product_attribute_value_id,
self.a3,
)
self.assertFalse(self.order_line.product_no_variant_attribute_value_ids.ptav_active)
self.start_tour("/odoo", 'delete_product_attribute_value_tour', login="admin")

View file

@ -0,0 +1,241 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import HttpCase, tagged
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestProductCatalog(HttpCase, SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.res_model = cls.empty_order._name
cls.res_id = cls.empty_order.id
cls.base_url = cls.base_url()
cls.products = cls.product + cls.service_product
def setUp(self):
super().setUp()
self.authenticate(self.sale_manager.login, self.sale_manager.login)
def request_get_order_lines_info(self, products, **kwargs):
response = self.opener.post(
url=self.base_url + '/product/catalog/order_lines_info',
json={
'params': {
'res_model': self.res_model,
'order_id': self.res_id,
'product_ids': products.ids,
**kwargs,
},
}
)
return response.json()['result']
def request_update_order_line_info(self, product, quantity=1.0, **kwargs):
response = self.opener.post(
url=self.base_url + '/product/catalog/update_order_line_info',
json={
'params': {
'res_model': self.res_model,
'order_id': self.res_id,
'product_id': product.id,
'quantity': quantity,
**kwargs,
},
}
)
return response.json()['result']
def _get_default_catalog_data(self, product):
return {
'quantity': 0,
'readOnly': False,
'productType': product.type,
'price': product.lst_price,
}
def check_catalog_data(self, products, expected_data=None):
expected_data = expected_data or {}
catalog_data = self.request_get_order_lines_info(products=products)
for product in products:
self.assertIn(str(product.id), catalog_data)
product_expected_data = {
**self._get_default_catalog_data(product),
**expected_data.get(product.id, {}),
}
product_data = catalog_data[str(product.id)]
for key, value in product_expected_data.items():
self.assertEqual(
product_data[key],
value,
)
def _create_pricelist_discount_rules(self):
self.pricelist.item_ids = [
Command.create({
'min_quantity': 1.0,
'product_id': self.product.id,
'compute_price': 'percentage',
'percent_price': 50,
}),
Command.create({
'min_quantity': 2.0,
'product_id': self.service_product.id,
'compute_price': 'percentage',
'percent_price': 50,
})
]
def test_catalog_context(self):
action_data = self.empty_order.action_add_from_catalog()
catalog_context = action_data['context']
self.assertEqual(catalog_context['product_catalog_order_id'], self.empty_order.id)
self.assertEqual(catalog_context['product_catalog_order_model'], self.res_model)
self.assertEqual(
catalog_context['product_catalog_currency_id'],
self.empty_order.currency_id.id,
)
# Equal to false, as `price_unit` doesn't have a precision set.
self.assertFalse(catalog_context['product_catalog_digits'])
def test_empty_order_data(self):
self.check_catalog_data(self.products)
# TODO VFE in master, forbid updates when order is readonly
def test_readonly_order_data(self):
self.empty_order._action_cancel()
# Readonly order because in cancelled state
self.check_catalog_data(
self.service_product,
{
self.service_product.id: {'readOnly': True},
}
)
def test_data(self):
self.empty_order.order_line = [
Command.create({
'product_id': self.service_product.id,
'product_uom_qty': 1.0,
})
]
self.check_catalog_data(
self.products,
{
self.service_product.id: {'quantity': 1.0},
}
)
def test_data_with_pricelist_rules(self):
self._create_pricelist_discount_rules()
self.assertEqual(self.empty_order.pricelist_id, self.pricelist)
self.check_catalog_data(
self.products,
{
self.product.id: {'price': self.product.lst_price / 2},
}
)
def test_data_with_discounted_lines(self):
self._create_pricelist_discount_rules()
self.env['res.config.settings'].create({
# Discounts included in price
'group_product_pricelist': True,
'group_discount_per_so_line': True,
}).execute()
self.empty_order.order_line = [
Command.create({
'product_id': self.product.id,
})
]
sol = self.empty_order.order_line
self.assertEqual(sol.price_unit, self.product.lst_price)
self.assertEqual(sol.discount, 50)
self.check_catalog_data(
self.products,
{
self.product.id: {
'quantity': 1.0,
'price': self.product.lst_price / 2
},
}
)
def test_update(self):
self.assertFalse(self.empty_order.order_line)
# Add product to order
product = self.service_product
update_data = self.request_update_order_line_info(product=product)
sol = self.empty_order.order_line
self.assertEqual(sol.product_id, product)
self.assertEqual(sol.product_uom_qty, 1.0)
self.assertEqual(update_data, sol.price_unit)
self.assertEqual(update_data, product.lst_price)
def test_update_with_pricelist_rules(self):
self._create_pricelist_discount_rules()
self.env['res.config.settings'].create({
# Discounts included in price
'group_product_pricelist': True,
'group_discount_per_so_line': False,
}).execute()
# Add first item --> no discount
product = self.service_product
update_data = self.request_update_order_line_info(product=product)
sol = self.empty_order.order_line
self.assertRecordValues(
sol, [{
'product_id': product.id,
'product_uom_qty': 1.0,
'price_unit': product.lst_price,
'discount': 0.0,
}]
)
self.assertEqual(update_data, product.lst_price)
# Add a second item --> should trigger the pricelist discount
update_data = self.request_update_order_line_info(product=product, quantity=2.0)
self.assertRecordValues(
sol, [{
'product_id': product.id,
'product_uom_qty': 2.0,
'price_unit': product.lst_price / 2,
'discount': 0.0,
}]
)
self.assertEqual(update_data, product.lst_price / 2)
# Enable discounts, add item --> discount should be on discount field
self.env['res.config.settings'].create({
# Discounts included in price
'group_product_pricelist': True,
'group_discount_per_so_line': True,
}).execute()
update_data = self.request_update_order_line_info(product=product, quantity=3.0)
self.assertRecordValues(
sol, [{
'product_id': product.id,
'product_uom_qty': 3.0,
'price_unit': product.lst_price,
'discount': 50.0,
}]
)
self.assertEqual(update_data, product.lst_price / 2)
def test_remove_product_from_catalog_without_sol(self):
"""Test that removing a product from the catalog right after clicking Add button"""
product = self.service_product
update_data = self.request_update_order_line_info(product=product, quantity=0.0)
self.assertEqual(update_data, product.lst_price)

View file

@ -0,0 +1,465 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import tagged
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
from odoo.addons.product.tests.common import ProductVariantsCommon
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestProductConfiguratorData(HttpCaseWithUserDemo, ProductVariantsCommon, SaleCommon):
def request_get_values(self, product_template, ptav_ids=None):
base_url = product_template.get_base_url()
response = self.url_open(
url=base_url + '/sale/product_configurator/get_values',
json={
'params': {
'product_template_id': product_template.id,
'quantity': 1.0,
'currency_id': 1,
'so_date': str(self.env.cr.now()),
'product_uom_id': None,
'company_id': None,
'pricelist_id': None,
'ptav_ids': ptav_ids,
'only_main_product': False,
},
}
)
return response.json()['result']
def create_product_template_with_2_attributes(self):
return self.env['product.template'].create({
'name': 'Shirt',
'categ_id': self.product_category.id,
'attribute_line_ids': [
Command.create({
'attribute_id': self.size_attribute.id,
'value_ids': [
Command.set([
self.size_attribute_l.id,
self.size_attribute_m.id,
]),
],
}),
Command.create({
'attribute_id': self.color_attribute.id,
'value_ids': [
Command.set([
self.color_attribute_red.id,
self.color_attribute_blue.id,
])
],
}),
],
})
def create_product_template_with_attribute_no_variant(self):
return self.env['product.template'].create({
'name': 'Chair',
'categ_id': self.product_category.id,
'attribute_line_ids': [
Command.create({
'attribute_id': self.no_variant_attribute.id,
'value_ids': [
Command.set([
self.no_variant_attribute_extra.id
]),
],
}),
Command.create({
'attribute_id': self.color_attribute.id,
'value_ids': [
Command.set([
self.color_attribute_red.id,
self.color_attribute_blue.id,
])
],
}),
],
})
def create_product_template_with_2_attribute_no_variant(self):
return self.env['product.template'].create({
'name': 'Chair',
'categ_id': self.product_category.id,
'attribute_line_ids': [
Command.create({
'attribute_id': self.no_variant_attribute.id,
'value_ids': [
Command.set([
self.no_variant_attribute_extra.id,
self.no_variant_attribute_second.id,
]),
],
}),
Command.create({
'attribute_id': self.color_attribute.id,
'value_ids': [
Command.set([
self.color_attribute_red.id,
self.color_attribute_blue.id,
])
],
}),
],
})
def test_dropped_value_isnt_shown(self):
self.assertEqual(len(self.product_template_sofa.product_variant_ids), 3)
# Use variants s.t. they are archived and not deleted when value is removed
self.empty_order.order_line = [
Command.create({
'product_id': product.id
})
for product in self.product_template_sofa.product_variant_ids
]
self.empty_order.action_confirm()
# Remove attribute value
self.product_template_sofa.attribute_line_ids.value_ids -= self.color_attribute_red
self.assertEqual(len(self.product_template_sofa.product_variant_ids.filtered('active')), 2)
self.authenticate('demo', 'demo')
result = self.request_get_values(self.product_template_sofa)
# Make sure the inactive ptav was removed from the loaded attributes
self.assertEqual(len(result['products'][0]['attribute_lines'][0]['attribute_values']), 2)
def test_dropped_attribute(self):
product_template = self.create_product_template_with_2_attributes()
self.assertEqual(len(product_template.product_variant_ids), 4)
# Use variants s.t. they are archived and not deleted when value is removed
self.empty_order.order_line = [
Command.create({
'product_id': product.id
})
for product in product_template.product_variant_ids
]
self.empty_order.action_confirm()
# Remove attribute
product_template.attribute_line_ids[0].unlink()
self.assertEqual(len(product_template.product_variant_ids), 2)
self.authenticate('demo', 'demo')
result = self.request_get_values(product_template)
# Make sure archived combinations with inactive ptav are not loaded as it's useless to
# exclude combinations that are not even available
self.assertFalse(result['products'][0]['archived_combinations'])
def test_dropped_attribute_value(self):
product_template = self.create_product_template_with_2_attributes()
self.assertEqual(len(product_template.product_variant_ids), 4)
# Use variants s.t. they are archived and not deleted when value is removed
self.empty_order.order_line = [
Command.create(
{
'product_id': product.id
}
)
for product in product_template.product_variant_ids
]
self.empty_order.action_confirm()
# Remove attribute value red
product_template.attribute_line_ids.filtered(
lambda ptal: ptal.attribute_id == self.color_attribute
).value_ids = [Command.unlink(self.color_attribute_red.id)]
self.assertEqual(len(product_template.product_variant_ids), 2)
archived_variants = product_template.with_context(
active_test=False
).product_variant_ids - product_template.product_variant_ids
self.assertEqual(len(archived_variants), 2)
archived_ptav = product_template.attribute_line_ids.product_template_value_ids.filtered(
lambda ptav: ptav.product_attribute_value_id == self.color_attribute_red
)
# Choose the variant (red, L)
variant_ptav_ids = [
archived_ptav.id,
product_template.attribute_line_ids.product_template_value_ids.filtered(
lambda ptav: ptav.product_attribute_value_id == self.size_attribute_l
).id,
]
self.authenticate('demo', 'demo')
result = self.request_get_values(product_template, variant_ptav_ids)
archived_ptav = archived_variants.product_template_attribute_value_ids.filtered(
lambda ptav: ptav.product_attribute_value_id == self.color_attribute_red
)
# When requested combination contains inactive ptav
# check that archived combinations are loaded
self.assertEqual(
len(result['products'][0]['archived_combinations']),
2
)
for combination in result['products'][0]['archived_combinations']:
self.assertIn(archived_ptav.id, combination)
# When requested combination contains inactive ptav check that exclusions contains it
self.assertIn(str(archived_ptav.id), result['products'][0]['exclusions'])
def test_excluded_inactive_ptav(self):
product_template = self.create_product_template_with_2_attributes()
self.assertEqual(len(product_template.product_variant_ids), 4)
ptav_with_exclusion = product_template.attribute_line_ids[0].product_template_value_ids[0]
ptav_excluded = product_template.attribute_line_ids[1].product_template_value_ids[0]
# Add an exclusion
ptav_with_exclusion.write({
'exclude_for': [
Command.create({
'product_tmpl_id': product_template.id,
'value_ids': [
Command.set([
ptav_excluded.id,
]),
],
}),
],
})
self.assertEqual(len(product_template.product_variant_ids), 3)
self.authenticate('demo', 'demo')
result = self.request_get_values(product_template)
# The PTAVs should be mutually excluded
self.assertEqual(result['products'][0]['exclusions']
[str(ptav_with_exclusion.id)], [ptav_excluded.id])
self.assertEqual(result['products'][0]['exclusions']
[str(ptav_excluded.id)], [ptav_with_exclusion.id])
ptav_with_exclusion.write({'ptav_active': False})
result = self.request_get_values(product_template)
# The inactive PTAV should not be in the product exclusions dict
self.assertFalse(str(ptav_with_exclusion.id) in result['products'][0]['exclusions'])
# The inactive PTAV should not be in the exclusions of the excluded PTAV
self.assertEqual(result['products'][0]['exclusions'][str(ptav_excluded.id)], [])
ptav_with_exclusion.write({'ptav_active': True})
ptav_excluded.write({'ptav_active': False})
result = self.request_get_values(product_template)
# The excluded inactive PTAV should not be in the exclusions of the first PTAV
self.assertEqual(result['products'][0]['exclusions'][str(ptav_with_exclusion.id)], [])
# The excluded inactive PTAV should not be in the product exclusions dict
self.assertFalse(str(ptav_excluded.id) in result['products'][0]['exclusions'])
ptav_with_exclusion.write({'ptav_active': False})
ptav_excluded.write({'ptav_active': False})
result = self.request_get_values(product_template)
# The inactive PTAVs should not be in the product exclusions dict
self.assertFalse(str(ptav_with_exclusion.id) in result['products'][0]['exclusions'])
self.assertFalse(str(ptav_excluded.id) in result['products'][0]['exclusions'])
def test_ptal_values_set_for_no_variant_atribute(self):
'''
Test that selected_attribute_value_id is set for attribute with only one variant and
`create_variant`: `no_variant`.
'''
product_template = self.create_product_template_with_attribute_no_variant()
self.authenticate('demo', 'demo')
ptav_red = product_template.attribute_line_ids.product_template_value_ids.filtered(
lambda ptav: ptav.product_attribute_value_id == self.color_attribute_red
)
result = self.request_get_values(product_template, [ptav_red.id])
self.assertTrue(result['products'][0]['attribute_lines'][1]['selected_attribute_value_ids'])
def test_dropped_attribute_value_custom_no_variant(self):
product_template = self.create_product_template_with_2_attribute_no_variant()
# Use variants s.t. they are archived and not deleted when value is removed
self.empty_order.order_line = [
Command.create({
'product_id': product.id,
'product_no_variant_attribute_value_ids': product.attribute_line_ids.product_template_value_ids.filtered(
lambda p: p.attribute_id.create_variant == 'no_variant'
),
})
for product in product_template.product_variant_ids]
self.empty_order.action_confirm()
# Remove attribute value extra
product_template.attribute_line_ids.filtered(
lambda ptal: ptal.attribute_id == self.no_variant_attribute
).value_ids = [Command.unlink(self.no_variant_attribute_extra.id)]
archived_ptav = product_template.attribute_line_ids.product_template_value_ids.filtered(
lambda ptav: ptav.product_attribute_value_id == self.no_variant_attribute_extra
)
self.assertFalse(archived_ptav.ptav_active)
self.assertEqual(
product_template.attribute_line_ids.filtered(
lambda ptal: ptal.attribute_id == self.no_variant_attribute
).product_template_value_ids[0],
archived_ptav,
)
# Choose the variant (red)
variant_ptav_ids = [
product_template.attribute_line_ids.product_template_value_ids.filtered(
lambda ptav: ptav.product_attribute_value_id == self.color_attribute_red
).id,
]
self.authenticate('demo', 'demo')
result = self.request_get_values(product_template, variant_ptav_ids)
selected_values = [
selected_value
for product in result['products'][0]['attribute_lines']
for selected_value in product['selected_attribute_value_ids']]
# Make sure that deleted value is not selected
self.assertNotIn(archived_ptav.id, selected_values)
def test_attribute_removal(self):
product_template_2lines_2attributes = self.env['product.template'].create({
'name': '2 lines 2 attributes',
'uom_id': self.uom_unit.id,
'categ_id': self.product_category.id,
'attribute_line_ids': [
Command.create({
'attribute_id': self.color_attribute.id,
'value_ids': [Command.set([
self.color_attribute_red.id,
self.color_attribute_blue.id,
])],
}),
Command.create({
'attribute_id': self.size_attribute.id,
'value_ids': [Command.set([
self.size_attribute_s.id,
self.size_attribute_m.id,
])]
})
]
})
# Sell all variants
self.empty_order.order_line = [
Command.create({
'product_id': product.id,
})
for product in product_template_2lines_2attributes.product_variant_ids
]
def _get_ptavs():
return product_template_2lines_2attributes.with_context(
active_test=False
).attribute_line_ids.product_template_value_ids
def _get_archived_variants():
return product_template_2lines_2attributes.with_context(
active_test=False
).product_variant_ids.filtered(lambda p: not p.active)
def _get_active_variants():
return product_template_2lines_2attributes.product_variant_ids
self.assertEqual(len(_get_ptavs()), 4)
product_template_2lines_2attributes.attribute_line_ids = [
Command.unlink(product_template_2lines_2attributes.attribute_line_ids.filtered(
lambda ptal: ptal.attribute_id.id == self.size_attribute.id
).id)
]
self.assertEqual(len(_get_ptavs()), 4)
# Use products s.t. they are archived and not deleted
self.empty_order.order_line = [
Command.create({
'product_id': product.id,
})
for product in product_template_2lines_2attributes.product_variant_ids
]
self.assertEqual(len(_get_archived_variants()), 4)
self.assertEqual(len(_get_active_variants()), 2)
product_template_2lines_2attributes.attribute_line_ids = [
Command.create({
'attribute_id': self.size_attribute.id,
'value_ids': [Command.set([
self.size_attribute_s.id,
])]
})
]
self.assertEqual(len(_get_ptavs()), 4)
self.assertEqual(len(_get_active_variants()), 2)
self.assertEqual(len(_get_archived_variants()), 4)
# When adding a single attribute line, the attribute will be added to all existing variants
# Instead of unarchiving existing archived variants with the same combination
# Leading to a state where the database holds two variants with the same combination
# We don't want this combination to be excluded from the product configurator as it is valid
# as long as there is one active variant with this configuration.
exclusions_data = product_template_2lines_2attributes._get_attribute_exclusions()
self.assertTrue(
all(
tuple(product.product_template_attribute_value_ids.ids) not in exclusions_data['archived_combinations']
for product in _get_active_variants()
)
)
def test_multiple_attribute_lines_same_attribute(self):
"""
Test that product configurator works correctly when multiple attribute
lines reference the same attribute. This ensures no KeyError is raised
when building the attrs_map in _get_product_information.
"""
# Create a product template with two attribute lines referencing the same attribute
product_template = self.env['product.template'].create({
'name': 'Multi Size Shirt',
'categ_id': self.product_category.id,
'attribute_line_ids': [
Command.create({
'attribute_id': self.size_attribute.id,
'value_ids': [
Command.set([
self.size_attribute_l.id,
]),
],
}),
Command.create({
'attribute_id': self.color_attribute.id,
'value_ids': [
Command.set([
self.color_attribute_red.id,
self.color_attribute_blue.id,
])
],
}),
Command.create({
'attribute_id': self.size_attribute.id,
'value_ids': [
Command.set([
self.size_attribute_m.id,
]),
],
}),
],
})
self.authenticate('demo', 'demo')
# This should not raise a KeyError
result = self.request_get_values(product_template)
# Verify we got the expected number of attribute lines
self.assertEqual(len(result['products'][0]['attribute_lines']), 3)
# Verify that each attribute line has its attribute info correctly mapped
attribute_names = [
line['attribute']['name']
for line in result['products'][0]['attribute_lines']
]
self.assertIn('Size', attribute_names)
self.assertIn('Color', attribute_names)
# Count occurrences of 'Size' - should be 2 since we have two lines with the same attribute
self.assertEqual(attribute_names.count('Size'), 2)

View file

@ -1,312 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from freezegun import freeze_time
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_plan = cls.env['account.analytic.plan'].create({
'name': 'Plan',
'company_id': cls.partner_a.company_id.id,
})
cls.analytic_account = cls.env['account.analytic.account'].create({
'name': 'Test AA',
'code': 'TESTSALE_REINVOICE',
'company_id': cls.partner_a.company_id.id,
'plan_id': cls.analytic_plan.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):
# Required for `analytic_account_id` to be visible in the view
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
""" 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({
'product_id': self.company_data['product_order_cost'].id,
'product_uom_qty': 2,
'qty_delivered': 1,
'order_id': self.sale_order.id,
})
sale_order_line2 = self.env['sale.order.line'].create({
'product_id': self.company_data['product_delivery_cost'].id,
'product_uom_qty': 4,
'qty_delivered': 1,
'order_id': self.sale_order.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.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_order_cost']
line_form.quantity = 3.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
with move_form.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_delivery_cost']
line_form.quantity = 3.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
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.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_order_cost']
line_form.quantity = 2.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
with move_form.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_delivery_cost']
line_form.quantity = 2.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
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')
@freeze_time('2020-01-15')
def test_sales_team_invoiced(self):
""" Test invoiced field from sales team ony take into account the amount the sales channel has invoiced this month """
invoices = self.env['account.move'].create([
{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_date': '2020-01-10',
'invoice_line_ids': [(0, 0, {'product_id': self.product_a.id, 'price_unit': 1000.0})],
},
{
'move_type': 'out_refund',
'partner_id': self.partner_a.id,
'invoice_date': '2020-01-10',
'invoice_line_ids': [(0, 0, {'product_id': self.product_a.id, 'price_unit': 500.0})],
},
{
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_date': '2020-01-01',
'date': '2020-01-01',
'invoice_line_ids': [(0, 0, {'product_id': self.product_a.id, 'price_unit': 800.0})],
},
])
invoices.action_post()
for invoice in invoices:
self.env['account.payment.register']\
.with_context(active_model='account.move', active_ids=invoice.ids)\
.create({})\
._create_payments()
invoices.flush_model()
self.assertRecordValues(invoices.team_id, [{'invoiced': 500.0}])
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.
"""
# Required for `analytic_account_id` to be visible in the view
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
# create SO line and confirm SO (with only one line)
sale_order_line1 = self.env['sale.order.line'].create({
'product_id': self.company_data['product_delivery_sales_price'].id,
'product_uom_qty': 2,
'qty_delivered': 1,
'order_id': self.sale_order.id,
})
sale_order_line2 = self.env['sale.order.line'].create({
'product_id': self.company_data['product_order_sales_price'].id,
'product_uom_qty': 3,
'qty_delivered': 1,
'order_id': self.sale_order.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.invoice_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_distribution = {self.analytic_account.id: 100}
with move_form.invoice_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_distribution = {self.analytic_account.id: 100}
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.invoice_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_distribution = {self.analytic_account.id: 100}
with move_form.invoice_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_distribution = {self.analytic_account.id: 100}
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. """
# Required for `analytic_account_id` to be visible in the view
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
# confirm SO
sale_order_line = self.env['sale.order.line'].create({
'product_id': self.company_data['product_delivery_no'].id,
'product_uom_qty': 2,
'qty_delivered': 1,
'order_id': self.sale_order.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.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_delivery_no']
line_form.quantity = 3.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
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({
'product_id': self.company_data['product_delivery_cost'].id,
'discount': 100.00,
'order_id': self.sale_order.id,
})
so_line2 = self.env['sale.order.line'].create({
'product_id': self.company_data['product_delivery_sales_price'].id,
'discount': 100.00,
'order_id': self.sale_order.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")
def test_not_recomputing_unit_price_for_expensed_so_lines(self):
# Required for `analytic_account_id` to be visible in the view
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
# create SO line and confirm SO (with only one line)
sol_1 = self.env['sale.order.line'].create({
'product_id': self.company_data['product_order_cost'].id,
'product_uom_qty': 2,
'qty_delivered': 1,
'order_id': self.sale_order.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.invoice_line_ids.new() as line_form:
line_form.product_id = self.company_data['product_order_cost']
line_form.quantity = 3.0
line_form.analytic_distribution = {self.analytic_account.id: 100}
invoice = move_form.save()
invoice.action_post()
# update the quantity of the expensed line
sol_2 = self.sale_order.order_line.filtered(lambda sol: sol != sol_1 and sol.product_id == self.company_data['product_order_cost'])
sol_2_subtotal_before = sol_2.price_unit
sol_2.product_uom_qty = 3.0
sol_2_subtotal_after = sol_2.price_unit
self.assertEqual(sol_2_subtotal_before, sol_2_subtotal_after)

View file

@ -0,0 +1,228 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import HttpCase, tagged
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestSaleComboConfigurator(HttpCase, SaleCommon):
def test_sale_combo_configurator(self):
if self.env['ir.module.module']._get('sale_management').state != 'installed':
self.skipTest("Sale App is not installed, Sale menu is not accessible.")
no_variant_attribute = self.env['product.attribute'].create({
'name': "No variant attribute",
'create_variant': 'no_variant',
'value_ids': [
Command.create({'name': "A"}),
Command.create({'name': "B", 'is_custom': True, 'default_extra_price': 1}),
],
})
product_a1 = self.env['product.template'].create({
'name': "Product A1",
'list_price': 100,
'attribute_line_ids': [
Command.create({
'attribute_id': no_variant_attribute.id,
'value_ids': [Command.set(no_variant_attribute.value_ids.ids)],
}),
],
})
combo_a = self.env['product.combo'].create({
'name': "Combo A",
'combo_item_ids': [
Command.create({'product_id': product_a1.product_variant_id.id, 'extra_price': 5}),
Command.create({'product_id': self._create_product(name="Product A2").id}),
],
})
combo_b = self.env['product.combo'].create({
'name': "Combo B",
'combo_item_ids': [
Command.create({'product_id': self._create_product(name="Product B1").id}),
Command.create({'product_id': self._create_product(name="Product B2").id}),
],
})
self._create_product(
name="Combo product",
list_price=25,
type='combo',
combo_ids=[
Command.link(combo_a.id),
Command.link(combo_b.id),
],
)
self.start_tour('/', 'sale_combo_configurator', login='salesman')
def test_sale_combo_configurator_with_optional_products(self):
if self.env['ir.module.module']._get('sale_management').state != 'installed':
self.skipTest("Sale App is not installed, Sale menu is not accessible.")
combo_a = self.env['product.combo'].create({
'name': "Combo A",
'combo_item_ids': [
Command.create({'product_id': self._create_product(name="Product A1").id}),
],
})
combo_b = self.env['product.combo'].create({
'name': "Combo B",
'combo_item_ids': [
Command.create({'product_id': self._create_product(name="Product B1").id}),
Command.create({'product_id': self._create_product(name="Product B2").id}),
],
})
optional_product = self.env['product.template'].create({
'name': "Optional Product",
})
combo_product = self.env['product.template'].create({
'name': "Combo product",
'list_price': 25,
'type': 'combo',
'combo_ids': [
Command.link(combo_a.id),
Command.link(combo_b.id),
],
'optional_product_ids': [Command.link(optional_product.id)],
})
self.start_tour('/', 'sale_combo_configurator_with_optional_products', login='salesman')
order = self.env['sale.order'].search([('partner_id.name', '=', 'Test Partner')], limit=1)
self.assertTrue(order, "A new Sale order should be created.")
self.assertEqual(
order.order_line[0].product_template_id,
combo_product,
"The main combo product should be added",
)
self.assertEqual(
order.order_line[1].product_template_id.name,
'Product A1',
"Product A1 should be added as a part of this combo",
)
self.assertEqual(
order.order_line[2].product_template_id.name,
'Product B2',
"Product B2 should be added as a part of this combo",
)
self.assertEqual(
order.order_line[3].product_template_id,
optional_product,
"Optional product should be added as a part of this combo",
)
def test_sale_combo_configurator_preselect_single_unconfigurable_items(self):
self.env['res.users'].search([('login', '=', 'salesman')]).group_ids += self.env.ref("product.group_product_manager")
if self.env['ir.module.module']._get('sale_management').state != 'installed':
self.skipTest("Sale App is not installed, Sale menu is not accessible.")
unconfigurable_no_variant_attribute = self.env['product.attribute'].create({
'name': "Attribute A",
'create_variant': 'no_variant',
'value_ids': [Command.create({'name': "A"})],
})
configurable_no_variant_attribute = self.env['product.attribute'].create({
'name': "Attribute B",
'create_variant': 'no_variant',
'display_type': 'multi',
'value_ids': [Command.create({'name': "B"})],
})
unconfigurable_always_attribute = self.env['product.attribute'].create({
'name': "Attribute C",
'create_variant': 'always',
'value_ids': [Command.create({'name': "C"})],
})
configurable_always_attribute = self.env['product.attribute'].create({
'name': "Attribute D",
'create_variant': 'always',
'value_ids': [Command.create({'name': "D", 'is_custom': True})],
})
unconfigurable_no_variant_combo = self._create_combo_from_attribute(
unconfigurable_no_variant_attribute, "Product A", "Combo A"
)
configurable_no_variant_combo = self._create_combo_from_attribute(
configurable_no_variant_attribute, "Product B", "Combo B"
)
unconfigurable_always_combo = self._create_combo_from_attribute(
unconfigurable_always_attribute, "Product C", "Combo C"
)
configurable_always_combo = self._create_combo_from_attribute(
configurable_always_attribute, "Product D", "Combo D"
)
combo_with_multiple_unconfigurable_items = self.env['product.combo'].create({
'name': "Combo E",
'combo_item_ids': [
Command.create({'product_id': self._create_product(name="Product E1").id}),
Command.create({'product_id': self._create_product(name="Product E2").id}),
],
})
self._create_product(
name="Combo product",
type='combo',
combo_ids=[
Command.link(unconfigurable_no_variant_combo.id),
Command.link(configurable_no_variant_combo.id),
Command.link(unconfigurable_always_combo.id),
Command.link(configurable_always_combo.id),
Command.link(combo_with_multiple_unconfigurable_items.id),
],
)
self.start_tour(
'/', 'sale_combo_configurator_preselect_single_unconfigurable_items', login='salesman'
)
def test_sale_combo_configurator_preconfigure_unconfigurable_ptals(self):
if self.env['ir.module.module']._get('sale_management').state != 'installed':
self.skipTest("Sale App is not installed, Sale menu is not accessible.")
unconfigurable_no_variant_attribute = self.env['product.attribute'].create({
'name': "Attribute A",
'create_variant': 'no_variant',
'value_ids': [Command.create({'name': "A"})],
})
configurable_no_variant_attribute = self.env['product.attribute'].create({
'name': "Attribute B",
'create_variant': 'no_variant',
'display_type': 'multi',
'value_ids': [Command.create({'name': "B"})],
})
product = self.env['product.template'].create({
'name': "Test product",
'attribute_line_ids': [
Command.create({
'attribute_id': unconfigurable_no_variant_attribute.id,
'value_ids': [Command.set(unconfigurable_no_variant_attribute.value_ids.ids)],
}),
Command.create({
'attribute_id': configurable_no_variant_attribute.id,
'value_ids': [Command.set(configurable_no_variant_attribute.value_ids.ids)],
}),
],
})
combo = self.env['product.combo'].create({
'name': "Test combo",
'combo_item_ids': [Command.create({'product_id': product.product_variant_id.id})],
})
self._create_product(
name="Combo product",
type='combo',
combo_ids=[Command.link(combo.id)],
)
self.start_tour(
'/', 'sale_combo_configurator_preconfigure_unconfigurable_ptals', login='salesman'
)
def _create_combo_from_attribute(self, attribute, product_name, combo_name):
product = self.env['product.template'].create({
'name': product_name,
'attribute_line_ids': [
Command.create({
'attribute_id': attribute.id,
'value_ids': [Command.set(attribute.value_ids.ids)],
}),
],
})
return self.env['product.combo'].create({
'name': combo_name,
'combo_item_ids': [Command.create({'product_id': product.product_variant_id.id})],
})

View file

@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
from odoo.addons.sale.tests.common import TestSaleCommonBase
from odoo.addons.sale.tests.common import TestSaleCommon
class TestSaleFlow(TestSaleCommonBase):
class TestSaleFlow(TestSaleCommon):
''' Test running at-install to test flows independently to other modules, e.g. 'sale_stock'. '''
@classmethod
@ -12,7 +11,7 @@ class TestSaleFlow(TestSaleCommonBase):
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)],
'group_ids': [(6, 0, cls.env.user.group_ids.ids), (4, cls.env.ref('account.group_account_user').id)],
})
user.partner_id.email = 'saleman@test.com'
@ -20,12 +19,6 @@ class TestSaleFlow(TestSaleCommonBase):
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,
@ -33,7 +26,6 @@ class TestSaleFlow(TestSaleCommonBase):
cls.analytic_plan = cls.env['account.analytic.plan'].create({
'name': 'Plan',
'company_id': cls.company.id,
})
cls.analytic_account = cls.env['account.analytic.account'].create({
@ -54,15 +46,12 @@ class TestSaleFlow(TestSaleCommonBase):
'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, {
@ -70,7 +59,6 @@ class TestSaleFlow(TestSaleCommonBase):
'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,
}),
],

View file

@ -1,18 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import HttpCase
class TestOnboarding(HttpCase):
def test_01_get_sample_sales_order_from_scratch(self):
# Make sure there are no QO nor products
if 'loyalty.reward' in self.env:
self.env['loyalty.reward'].search([]).active = False
self.env['sale.order'].search([
('company_id', '=', self.env.company.id),
('partner_id', '=', self.env.user.partner_id.id),
('state', '=', 'draft')
]).state = 'cancel'
self.env['product.product'].search([]).active = False
self.env.company._get_sample_sales_order()

File diff suppressed because it is too large Load diff

View file

@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import users
from odoo.addons.sale.tests.common import SaleCommon
from odoo.addons.sales_team.tests.common import TestSalesCommon
class TestSaleOrderCancel(SaleCommon, TestSalesCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.template = cls.env.ref('sale.mail_template_sale_cancellation')
cls.template.write({
'subject': 'I can see {{ len(object.partner_id.sale_order_ids) }} order(s)',
'body_html': 'I can see <t t-out="len(object.partner_id.sale_order_ids)"/> order(s)',
})
cls.partner = cls.env['res.partner'].create({'name': 'foo'})
cls.manager_order, cls.salesman_order = cls.env['sale.order'].create([
{'partner_id': cls.partner.id, 'user_id': cls.user_sales_manager.id},
{'partner_id': cls.partner.id, 'user_id': cls.user_sales_salesman.id}
])
# Invalidate the cache, e.g. to clear the computation of partner.sale_order_ids
cls.env.invalidate_all()
@users('user_sales_salesman')
def test_salesman_record_rules(self):
cancel = self.env['sale.order.cancel'].create({
'template_id': self.template.id,
'order_id': self.salesman_order.id,
})
self.assertEqual(cancel.subject, 'I can see 1 order(s)')
self.assertEqual(cancel.body, 'I can see 1 order(s)')
@users('user_sales_manager')
def test_manager_record_rules(self):
cancel = self.env['sale.order.cancel'].create({
'template_id': self.template.id,
'order_id': self.manager_order.id,
})
self.assertEqual(cancel.subject, 'I can see 2 order(s)')
self.assertEqual(cancel.body, 'I can see 2 order(s)')

View file

@ -0,0 +1,41 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import HttpCase, tagged
@tagged('-at_install', 'post_install')
class TestSaleOrderProductCatalog(HttpCase):
def test_sale_order_product_catalog_branch_company_tour(self):
"""Test adding products to a SO through the catalog view when in a branch company."""
self.env['product.template'].create({
'name': "Restricted Product",
'company_id': self.env.company.id,
})
self.env['res.partner'].create({
'name': "Test Partner",
})
admin = self.env.ref('base.user_admin')
branch = self.env['res.company'].with_user(admin).create({
'name': "Branch Company",
'parent_id': self.env.company.id,
})
admin.company_id = branch
self.env['product.template'].create({
'name': "AAA Product",
'company_id': admin.company_id.id,
})
self.start_tour(
'/web#action=sale.action_quotations',
'sale_catalog',
login="admin",
)
def test_add_section_from_product_catalog_on_sale_order_tour(self):
self.env['product.template'].create({'name': "Test Product", 'is_favorite': True})
self.start_tour(
'/web#action=sale.action_quotations',
'test_add_section_from_product_catalog_on_sale_order',
login='admin',
)

View file

@ -0,0 +1,29 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import HttpCase, tagged
from odoo.addons.product.tests.common import ProductVariantsCommon
@tagged('post_install', '-at_install')
class TestSaleOrderUI(HttpCase, ProductVariantsCommon):
def test_sale_order_keep_uom_on_variant_wizard_quantity_change(self):
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [Command.create({
'product_id': self.product_template_sofa.product_variant_ids[0].id,
'product_uom_id': self.uom_dozen.id,
'product_uom_qty': 1,
})],
})
self.start_tour(
f'/odoo/sales/{so.id}',
'sale_order_keep_uom_on_variant_wizard_quantity_change',
login="admin",
)
sol = so.order_line[0]
self.assertEqual(sol.product_uom_id, self.uom_dozen)

View file

@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from freezegun import freeze_time
from datetime import timedelta
from freezegun import freeze_time
from odoo import fields
from odoo.fields import Command
from odoo.tests import Form, tagged
from odoo.tools import float_compare, mute_logger, float_round
from odoo.tools import float_compare, float_round, mute_logger
from odoo.addons.sale.tests.common import SaleCommon
@ -19,11 +19,18 @@ class TestSalePrices(SaleCommon):
def setUpClass(cls):
super().setUpClass()
cls._enable_discounts()
cls.discount = 10 # %
# Needed when run without demo data
# s.t. taxes creation doesn't fail
cls.env.company.account_fiscal_country_id = cls.env.ref('base.be')
belgium = cls.env.ref('base.be')
cls.env.company.account_fiscal_country_id = belgium
for model in ('account.tax', 'account.tax.group'):
cls.env.add_to_compute(
cls.env[model]._fields['country_id'],
cls.env[model].search([('company_id', '=', cls.env.company.id)]),
)
def _create_discount_pricelist_rule(self, **additional_values):
return self.env['product.pricelist.item'].create({
@ -40,7 +47,6 @@ class TestSalePrices(SaleCommon):
)
product_price = self.product.lst_price
product_dozen_price = product_price * 12
discount = 1 - self.discount/100
self.empty_order.order_line = [
Command.create({
@ -58,41 +64,32 @@ class TestSalePrices(SaleCommon):
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1.0,
'product_uom': self.uom_dozen.id,
'product_uom_id': self.uom_dozen.id,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 0.4,
'product_uom': self.uom_dozen.id,
'product_uom_id': self.uom_dozen.id,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 0.3,
'product_uom': self.uom_dozen.id,
'product_uom_id': self.uom_dozen.id,
})
]
discounted_lines = self.empty_order.order_line.filtered('pricelist_item_id')
self.assertEqual(discounted_lines, self.empty_order.order_line[1:5])
self.assertEqual(discounted_lines.pricelist_item_id, pricelist_rule)
self.assertTrue(all(not line.discount for line in self.empty_order.order_line))
self.assertEqual(
discounted_lines.mapped('price_unit'),
[
product_price*discount,
product_price*discount,
product_dozen_price*discount,
product_dozen_price*discount
]
)
self.pricelist.discount_policy = 'without_discount'
self.empty_order._recompute_prices()
self.assertTrue(all(not line.discount for line in self.empty_order.order_line - discounted_lines))
self.assertEqual(
discounted_lines.mapped('price_unit'),
[product_price, product_price, product_dozen_price, product_dozen_price])
self.assertEqual(discounted_lines.mapped('discount'), [self.discount]*len(discounted_lines))
discounted_lines[0].product_uom_qty = 3.0
self.assertFalse(discounted_lines[0].discount)
def test_pricelist_dates(self):
""" Verify the order date is correctly provided to the pricelist API"""
today = fields.Datetime.today()
@ -111,11 +108,9 @@ class TestSalePrices(SaleCommon):
'product_id': self.product.id,
})
self.assertEqual(order_line.pricelist_item_id, pricelist_rule)
self.assertEqual(
order_line.price_unit,
self.product.lst_price * (1 - self.discount / 100.0))
self.assertEqual(order_line.discount, 0.0)
self.assertAlmostEqual(order_line.pricelist_item_id, pricelist_rule)
self.assertAlmostEqual(order_line.price_unit, self.product.lst_price)
self.assertEqual(order_line.discount, 10)
# Create an order tomorrow, add line today, rule active today doesn't work
self.empty_order.date_order = tomorrow
@ -150,8 +145,8 @@ class TestSalePrices(SaleCommon):
self.assertEqual(order_line.pricelist_item_id, pricelist_rule)
self.assertEqual(
order_line.price_unit,
self.product.lst_price * (1 - self.discount / 100.0))
self.assertEqual(order_line.discount, 0.0)
self.product.lst_price)
self.assertEqual(order_line.discount, 10)
self.assertEqual(
self.empty_order.amount_untaxed,
@ -206,7 +201,7 @@ class TestSalePrices(SaleCommon):
self.empty_order.order_line = [
Command.create({
'product_id': self.product.id,
'product_uom': self.uom_dozen.id,
'product_uom_id': self.uom_dozen.id,
'product_uom_qty': 2.0,
}),
]
@ -220,8 +215,8 @@ class TestSalePrices(SaleCommon):
with freeze_time('2022-08-19'):
self.env['res.currency.rate'].create({
'name': fields.Date.today(),
'rate': 1.0,
'currency_id': self.env.company.currency_id.id,
'rate': 2.0,
'currency_id': other_currency.id,
'company_id': self.env.company.id,
})
order_in_other_currency = self.env['sale.order'].create({
@ -230,12 +225,13 @@ class TestSalePrices(SaleCommon):
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom': self.uom_dozen.id,
'product_uom_id': self.uom_dozen.id,
'product_uom_qty': 2.0,
}),
]
})
self.assertEqual(order_in_other_currency.amount_total, 480.0)
# 20.0 (product price) * 24.0 (2 dozens) * 2.0 (price rate USD -> EUR)
self.assertEqual(order_in_other_currency.amount_total, 960.0)
def test_negative_discounts(self):
"""aka surcharges"""
@ -250,7 +246,6 @@ class TestSalePrices(SaleCommon):
# Even when the discount is supposed to be shown
# Surcharges shouldn't be shown to the user
self.pricelist.discount_policy = 'without_discount'
order_line = self.env['sale.order.line'].create({
'order_id': self.empty_order.id,
'product_id': self.product.id,
@ -264,7 +259,6 @@ class TestSalePrices(SaleCommon):
base_pricelist = self.env['product.pricelist'].create({
'name': 'First pricelist',
'discount_policy': 'without_discount',
'item_ids': [Command.create({
'compute_price': 'percentage',
'base': 'list_price',
@ -275,12 +269,11 @@ class TestSalePrices(SaleCommon):
})
self.pricelist.write({
'discount_policy': 'without_discount',
'item_ids': [Command.create({
'compute_price': 'formula',
'compute_price': 'percentage',
'base': 'pricelist',
'base_pricelist_id': base_pricelist.id,
'price_discount': 10,
'percent_price': 10,
'applied_on': '3_global',
'name': 'Second discount',
})],
@ -297,7 +290,9 @@ class TestSalePrices(SaleCommon):
self.assertEqual(order_line.pricelist_item_id, self.pricelist.item_ids)
self.assertEqual(order_line.price_subtotal, 81, "Second pricelist rule not applied")
self.assertEqual(order_line.discount, 19, "Second discount not applied")
self.assertEqual(
order_line.discount, 19,
"Discount not computed correctly based on both pricelists")
def test_pricelist_with_another_currency(self):
""" Test prices are correctly applied with a pricelist with another currency"""
@ -316,17 +311,14 @@ class TestSalePrices(SaleCommon):
).unlink()
new_uom = self.env['uom.uom'].create({
'name': '10 units',
'factor_inv': 10,
'uom_type': 'bigger',
'rounding': 1.0,
'category_id': self.uom_unit.category_id.id,
'relative_factor': 10,
'relative_uom_id': self.uom_unit.id,
})
# This pricelist doesn't show the discount
pricelist_eur = self.env['product.pricelist'].create({
'name': 'First pricelist',
'currency_id': currency_eur.id,
'discount_policy': 'with_discount',
'item_ids': [Command.create({
'compute_price': 'percentage',
'base': 'list_price',
@ -347,13 +339,76 @@ class TestSalePrices(SaleCommon):
})
# force compute uom and prices
self.assertEqual(order_line.price_unit, 180, "First pricelist rule not applied")
order_line.product_uom = new_uom
self.assertEqual(order_line.price_unit, 1800, "First pricelist rule not applied")
self.assertEqual(order_line.discount, 10, "First pricelist rule not applied")
order_line.product_uom_id = new_uom
self.assertEqual(order_line.price_total, 1800, "First pricelist rule not applied")
def test_pricelist_price_recompute_on_quantity_change(self):
"""
Test price updates correctly when quantity changes with
pricelist based on another pricelist.
"""
self._enable_pricelists()
pricelist_a = self.env['product.pricelist'].create({
'name': "Pricelist A",
'item_ids': [
Command.create({
'applied_on': '3_global',
'compute_price': 'fixed',
'fixed_price': 0.75,
'min_quantity': 0,
}),
Command.create({
'applied_on': '3_global',
'compute_price': 'fixed',
'fixed_price': 0.50,
'min_quantity': 1000,
}),
],
})
pricelist_b = self.env['product.pricelist'].create({
'name': "Pricelist B",
'item_ids': [
Command.create({
'applied_on': '3_global',
'compute_price': 'percentage',
'percent_price': -10,
'base': 'pricelist',
'base_pricelist_id': pricelist_a.id,
}),
],
})
with Form(self.env['sale.order']) as order_form:
order_form.partner_id = self.partner
order_form.pricelist_id = pricelist_b
with order_form.order_line.new() as line_form:
line_form.product_id = self.product
self.assertAlmostEqual(line_form.price_unit, 0.825)
line_form.product_uom_qty = 1000
self.assertAlmostEqual(line_form.price_unit, 0.55)
def test_compute_price_unit_no_currency(self):
new_order = self.env['sale.order'].new({
'currency_id': False,
'pricelist_id': False,
'order_line': [Command.create({'product_id': self.product.id})],
})
new_line = new_order.order_line
self.assertEqual(new_line.price_unit, self.product.list_price)
new_line.price_unit = new_price = self.product.list_price + 0.5
new_line.product_uom_qty += 1.0
self.assertEqual(new_line.price_unit, new_price, "Manual unit price shouldn't change")
new_order._recompute_prices()
self.assertEqual(new_line.price_unit, self.product.list_price)
def test_multi_currency_discount(self):
"""Verify the currency used for pricelist price & discount computation."""
product_1 = self.consumable_product
product_1 = self.product
product_2 = self.service_product
# Make sure the company is in USD
@ -371,7 +426,12 @@ class TestSalePrices(SaleCommon):
'company_ids': [Command.set([other_company.id])],
'name': 'E.T',
'login': 'hohoho',
'group_ids': (
self.env.ref('sales_team.group_sale_salesman') |
self.env.ref('product.group_product_manager')
),
})
user_in_other_company.group_ids += self.env.ref("product.group_product_manager")
with mute_logger('odoo.models.unlink'):
self.env['res.currency.rate'].search([]).unlink()
self.env['res.currency.rate'].create({
@ -401,7 +461,7 @@ class TestSalePrices(SaleCommon):
pricelist = self.env['product.pricelist'].create({
'name': 'Test multi-currency',
'discount_policy': 'without_discount',
'company_id': False,
'currency_id': other_curr.id,
'item_ids': [
Command.create({
@ -428,7 +488,7 @@ class TestSalePrices(SaleCommon):
# 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,
'partner_id': user_in_other_company.partner_id.id,
'pricelist_id': pricelist.id,
'order_line': [
Command.create({
@ -455,7 +515,7 @@ class TestSalePrices(SaleCommon):
# 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,
'partner_id': user_in_other_company.partner_id.id,
'pricelist_id': pricelist.id,
'order_line': [
# Verify discount is considered in create hack
@ -485,6 +545,7 @@ class TestSalePrices(SaleCommon):
"""
sale_order = self.sale_order
so_amount = sale_order.amount_total
start_so_amount = so_amount
sale_order._recompute_prices()
self.assertEqual(
sale_order.amount_total, so_amount,
@ -492,42 +553,77 @@ class TestSalePrices(SaleCommon):
pricelist = sale_order.pricelist_id
pricelist.item_ids = [
fields.Command.create({
Command.create({
'percent_price': 5.0,
'compute_price': 'percentage'
})
]
pricelist.discount_policy = "without_discount"
sale_order._recompute_prices()
self.assertTrue(all(line.discount == 5 for line in sale_order.order_line))
self.assertEqual(sale_order.amount_undiscounted, so_amount)
self.assertEqual(sale_order.amount_total, 0.95*so_amount)
pricelist.discount_policy = "with_discount"
pricelist.item_ids = [
Command.create({
'price_discount': 5,
'compute_price': 'formula',
})
]
sale_order._recompute_prices()
self.assertTrue(all(line.discount == 0 for line in sale_order.order_line))
self.assertEqual(sale_order.amount_undiscounted, so_amount)
self.assertEqual(sale_order.amount_total, 0.95*so_amount)
# Test taking off the pricelist
sale_order.pricelist_id = False
sale_order._recompute_prices()
self.assertTrue(all(line.discount == 0 for line in sale_order.order_line))
self.assertEqual(sale_order.amount_undiscounted, so_amount)
self.assertEqual(
sale_order.amount_total, start_so_amount,
"The SO amount without pricelist should be the same than with an empty pricelist"
)
def test_manual_price_prevents_recompute(self):
sale_order_line = self.sale_order.order_line[0]
# Ensure initial price is set correctly
self.assertEqual(sale_order_line.price_unit, 20.0)
# Update the price manually and then change the quantity
with Form(sale_order_line) as line:
line.price_unit = 100.0
line.product_uom_qty = 10
self.assertEqual(
sale_order_line.price_unit, 100.0,
"Price should remain 100.0 after changing the quantity"
)
zero_price_product = self._create_product(list_price=0.0)
self.assertEqual(zero_price_product.list_price, 0.0)
so_line = self.env['sale.order.line'].create({
'product_id': zero_price_product.id,
'order_id': self.sale_order.id,
})
self.assertEqual(so_line.price_unit, 0.0)
self.assertEqual(so_line.technical_price_unit, 0.0)
with Form(so_line) as so_line:
so_line.price_unit = 10.0
so_line.product_uom_qty = 2.0
so_line.save()
self.assertEqual(so_line.price_unit, 10.0)
# Taxes tests:
# We do not rely on accounting common on purpose to avoid
# all the useless setup not needed here.
# If you need the accounting common (journals, ...), use/make another test class
def test_sale_tax_mapping(self):
tax_a, tax_b = self.env['account.tax'].create([{
'name': 'Test tax A',
'type_tax_use': 'sale',
'price_include': True,
'amount': 15.0,
}, {
'name': 'Test tax B',
'type_tax_use': 'sale',
'amount': 6.0,
}])
country_belgium = self.env['res.country'].search([
('name', '=', 'Belgium'),
], limit=1)
@ -535,11 +631,19 @@ class TestSalePrices(SaleCommon):
'name': 'Test Fiscal Position',
'auto_apply': True,
'country_id': country_belgium.id,
'tax_ids': [Command.create({
'tax_src_id': tax_a.id,
'tax_dest_id': tax_b.id
})]
})
tax_a, tax_b = self.env['account.tax'].create([{
'name': 'Test tax A',
'type_tax_use': 'sale',
'price_include_override': 'tax_included',
'amount': 15.0,
}, {
'name': 'Test tax B',
'type_tax_use': 'sale',
'amount': 6.0,
'fiscal_position_ids': fiscal_pos,
}])
tax_b.original_tax_ids = tax_a
# setting up partner:
self.partner.country_id = country_belgium
@ -550,7 +654,6 @@ class TestSalePrices(SaleCommon):
})
self.pricelist.write({
'discount_policy': 'without_discount',
'item_ids': [Command.create({
'applied_on': '3_global',
'compute_price': 'percentage',
@ -584,7 +687,7 @@ class TestSalePrices(SaleCommon):
"Wrong subtotal price computed for specified product & pricelist"
)
self.assertEqual(
self.empty_order.order_line.tax_id.id, tax_b.id,
self.empty_order.order_line.tax_ids.id, tax_b.id,
"Wrong tax applied for specified product & pricelist"
)
@ -598,6 +701,25 @@ class TestSalePrices(SaleCommon):
pricelist = self.pricelist
partner = self.partner
(
fpos_incl_incl,
fpos_excl_incl,
fpos_incl_excl,
fpos_excl_excl,
) = self.env['account.fiscal.position'].create([{
'name': "incl -> incl",
'sequence': 1,
}, {
'name': "excl -> incl",
'sequence': 2,
}, {
'name': "incl -> excl",
'sequence': 3,
}, {
'name': "excl -> excl",
'sequence': 4,
}])
(
tax_fixed_incl,
tax_fixed_excl,
@ -607,36 +729,45 @@ class TestSalePrices(SaleCommon):
tax_exclude_dst,
) = self.env['account.tax'].create([{
'name': "fixed include",
'amount': '10.00',
'amount': 10.00,
'amount_type': 'fixed',
'price_include': True,
'price_include_override': 'tax_included',
}, {
'name': "fixed exclude",
'amount': '10.00',
'amount': 10.00,
'amount_type': 'fixed',
'price_include': False,
'price_include_override': 'tax_excluded',
}, {
'name': "Include 21%",
'amount': 21.00,
'amount_type': 'percent',
'price_include': True,
'price_include_override': 'tax_included',
}, {
'name': "Include 6%",
'amount': 6.00,
'amount_type': 'percent',
'price_include': True,
'price_include_override': 'tax_included',
}, {
'name': "Exclude 15%",
'amount': 15.00,
'amount_type': 'percent',
'price_include': False,
'price_include_override': 'tax_excluded',
}, {
'name': "Exclude 21%",
'amount': 21.00,
'amount_type': 'percent',
'price_include': False,
'price_include_override': 'tax_excluded',
}])
tax_include_dst.write({
'fiscal_position_ids': fpos_incl_incl | fpos_excl_incl,
'original_tax_ids': tax_include_src | tax_exclude_src,
})
tax_exclude_dst.write({
'fiscal_position_ids': fpos_incl_excl | fpos_excl_excl,
'original_tax_ids': tax_include_src | tax_exclude_src,
})
(
product_tmpl_a,
product_tmpl_b,
@ -660,41 +791,6 @@ class TestSalePrices(SaleCommon):
'taxes_id': [Command.set([tax_fixed_excl.id, tax_include_src.id])]
}])
(
fpos_incl_incl,
fpos_excl_incl,
fpos_incl_excl,
fpos_excl_excl,
) = self.env['account.fiscal.position'].create([{
'name': "incl -> incl",
'sequence': 1,
'tax_ids': [Command.create({
'tax_src_id': tax_include_src.id,
'tax_dest_id': tax_include_dst.id,
})]
}, {
'name': "excl -> incl",
'sequence': 2,
'tax_ids': [Command.create({
'tax_src_id': tax_exclude_src.id,
'tax_dest_id': tax_include_dst.id,
})]
}, {
'name': "incl -> excl",
'sequence': 3,
'tax_ids': [Command.create({
'tax_src_id': tax_include_src.id,
'tax_dest_id': tax_exclude_dst.id,
})]
}, {
'name': "excl -> excp",
'sequence': 4,
'tax_ids': [Command.create({
'tax_src_id': tax_exclude_src.id,
'tax_dest_id': tax_exclude_dst.id,
})]
}])
# Create the SO with one SO line and apply a pricelist and fiscal position on it
# Then check if price unit and price subtotal matches the expected values
@ -775,31 +871,29 @@ class TestSalePrices(SaleCommon):
def test_so_tax_mapping(self):
order = self.empty_order
fpos = self.env['account.fiscal.position'].create({
'name': 'Test Fiscal Position',
'sequence': 1,
})
tax_include, tax_exclude = self.env['account.tax'].create([{
'name': 'Include Tax',
'amount': '21.00',
'price_include': True,
'price_include_override': 'tax_included',
'type_tax_use': 'sale',
}, {
'name': 'Exclude Tax',
'amount': '0.00',
'type_tax_use': 'sale',
'fiscal_position_ids': fpos,
}])
tax_exclude.original_tax_ids = tax_include
self.product.write({
'list_price': 121,
'taxes_id': [Command.set(tax_include.ids)]
})
fpos = self.env['account.fiscal.position'].create({
'name': 'Test Fiscal Position',
'sequence': 1,
'tax_ids': [Command.create({
'tax_src_id': tax_include.id,
'tax_dest_id': tax_exclude.id,
})],
})
order.write({
'fiscal_position_id': fpos.id,
'order_line': [Command.create({
@ -812,6 +906,46 @@ class TestSalePrices(SaleCommon):
100, order.order_line[0].price_unit,
"The included tax must be subtracted to the price")
def test_so_tax_mapping_multicompany(self):
fpos = self.env['account.fiscal.position'].create({'name': "B2B"})
tax_group = self.env['account.tax.group'].create({'name': "10%"})
tax_include = self.env['account.tax'].create({
'name': "10% Tax Inc.",
'type_tax_use': 'sale',
'amount': 10.0,
'price_include_override': 'tax_included',
'tax_group_id': tax_group.id,
'fiscal_position_ids': fpos.ids,
})
tax_exclude = tax_include.copy({
'name': "10% Tax Exc.",
'amount': 0.0,
'price_include_override': 'tax_excluded',
'original_tax_ids': tax_include.ids,
})
self.product.write({
'list_price': 110.0,
'taxes_id': tax_include.ids,
})
branch_company = self.env['res.company'].create({
'name': "Branch Co.",
'parent_id': self.env.company.id,
'account_fiscal_country_id': self.env.company.account_fiscal_country_id.id,
})
order = self.empty_order.with_company(branch_company)
order.sudo().write({
'company_id': branch_company.id,
'fiscal_position_id': fpos.id,
'user_id': False,
'team_id': False,
'order_line': [Command.create({'product_id': self.product.id})],
})
self.assertEqual(order.order_line.tax_ids, tax_exclude, "Line tax should be mapped")
self.assertAlmostEqual(
order.order_line.price_unit, 100.0,
msg="Tax should not be included in unit price",
)
def test_free_product_and_price_include_fixed_tax(self):
""" Check that fixed tax include are correctly computed while the price_unit is 0 """
taxes = self.env['account.tax'].create([{
@ -819,14 +953,14 @@ class TestSalePrices(SaleCommon):
'type_tax_use': 'sale',
'amount_type': 'fixed',
'amount': 0.05,
'price_include': True,
'price_include_override': 'tax_included',
'include_base_amount': True,
}, {
'name': 'Recupel 0.25',
'type_tax_use': 'sale',
'amount_type': 'fixed',
'amount': 0.25,
'price_include': True,
'price_include_override': 'tax_included',
'include_base_amount': True,
}])
order = self.empty_order
@ -834,7 +968,7 @@ class TestSalePrices(SaleCommon):
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 0.0,
'tax_id': [
'tax_ids': [
Command.set(taxes.ids),
],
})]
@ -855,18 +989,18 @@ class TestSalePrices(SaleCommon):
tax_include, tax_exclude = self.env['account.tax'].create([{
'name': 'Tax with price include',
'amount': 10,
'price_include': True
'price_include_override': 'tax_included',
}, {
'name': 'Tax with no price include',
'amount': 10,
}])
# Apply taxes on the sale order lines
self.sale_order.order_line[0].write({'tax_id': [Command.link(tax_include.id)]})
self.sale_order.order_line[1].write({'tax_id': [Command.link(tax_exclude.id)]})
self.sale_order.order_line[0].write({'tax_ids': [Command.link(tax_include.id)]})
self.sale_order.order_line[1].write({'tax_ids': [Command.link(tax_exclude.id)]})
for line in self.sale_order.order_line:
if line.tax_id.price_include:
if line.tax_ids.price_include:
price = line.price_unit * line.product_uom_qty - line.price_tax
else:
price = line.price_unit * line.product_uom_qty
@ -902,11 +1036,11 @@ class TestSalePrices(SaleCommon):
# Same with an included-in-price tax
order = order.copy()
line = order.order_line
line.tax_id = [Command.create({
line.tax_ids = [Command.create({
'name': 'Super Tax',
'amount_type': 'percent',
'amount': 15.0,
'price_include': True,
'price_include_override': 'tax_included',
})]
order.action_confirm()
self.assertEqual(line.untaxed_amount_to_invoice, 0)
@ -938,6 +1072,7 @@ class TestSalePrices(SaleCommon):
order_line.write({
'product_uom_qty': 3.0,
'price_unit': 100.0,
'discount': 1.0,
})
order.invalidate_recordset(['amount_undiscounted'])
@ -952,11 +1087,11 @@ class TestSalePrices(SaleCommon):
# Same with an included-in-price tax
order = order.copy()
line = order.order_line
line.tax_id = [Command.create({
line.tax_ids = [Command.create({
'name': 'Super Tax',
'amount_type': 'percent',
'amount': 10.0,
'price_include': True,
'price_include_override': 'tax_included',
})]
line.discount = 50.0
order.action_confirm()
@ -979,7 +1114,134 @@ class TestSalePrices(SaleCommon):
})]
order.action_confirm()
line = order.order_line
quantity_precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
expected_price_subtotal = line.price_unit * float_round(product_uom_qty, precision_digits=quantity_precision)
quantity_precision = self.env['decimal.precision'].precision_get('Product Unit')
self.assertEqual(
line.product_uom_qty, float_round(product_uom_qty, precision_digits=quantity_precision))
expected_price_subtotal = line.currency_id.round(
line.price_unit * float_round(product_uom_qty, precision_digits=quantity_precision))
self.assertAlmostEqual(line.price_subtotal, expected_price_subtotal)
self.assertEqual(order.amount_total, order.tax_totals.get('amount_total'))
self.assertEqual(order.amount_total, order.tax_totals.get('total_amount_currency'))
def test_show_discount(self):
"""
Test that discount is shown only when compute_price is percentage
If compute_price is formula, discount should be included in price.
"""
test_product_discount = self.env['product.product'].create({
'name': 'Test Product',
'list_price': 100.0,
'taxes_id': None,
})
test_product_incl_discount = self.env['product.product'].create({
'name': 'Test Product',
'list_price': 100.0,
'taxes_id': None,
})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': test_product_discount.id,
'product_uom_qty': 1.0,
}),
Command.create({
'product_id': test_product_incl_discount.id,
'product_uom_qty': 1,
})
]
})
self.assertEqual(200, sale_order.amount_total)
base_discount_pricelist = self.env['product.pricelist'].create({
'name': 'Base Discount Pricelist',
'item_ids': [
Command.create({
'name': 'Discount',
'applied_on': '1_product',
'product_tmpl_id': test_product_discount.product_tmpl_id.id,
'compute_price': 'percentage',
'percent_price': 10,
}),
Command.create({
'name': 'Formula',
'applied_on': '1_product',
'product_tmpl_id': test_product_incl_discount.product_tmpl_id.id,
'compute_price': 'formula',
'price_discount': 10,
}),
]})
sale_order.pricelist_id = base_discount_pricelist
sale_order._recompute_prices()
show_discount_line = sale_order.order_line[0]
included_discount_line = sale_order.order_line[1]
self.assertEqual(show_discount_line.price_unit, 100)
self.assertEqual(show_discount_line.price_subtotal, show_discount_line.price_unit * 0.9)
self.assertEqual(show_discount_line.discount, 10)
self.assertEqual(included_discount_line.price_unit, included_discount_line.price_subtotal)
self.assertEqual(included_discount_line.discount, 0)
# Test with discount based on other pricelist
discount_pricelist = self.env['product.pricelist'].create({
'name': 'Discount Pricelist',
'item_ids': [
Command.create({
'name': 'Discount based on pricelist',
'applied_on': '1_product',
'product_tmpl_id': test_product_discount.product_tmpl_id.id,
'compute_price': 'percentage',
'percent_price': 10,
'base': 'pricelist',
'base_pricelist_id': base_discount_pricelist.id,
}),
]})
sale_order.pricelist_id = discount_pricelist
sale_order._recompute_prices()
self.assertEqual(show_discount_line.price_unit, 100)
self.assertEqual(show_discount_line.price_subtotal, show_discount_line.price_unit * 0.81)
self.assertEqual(show_discount_line.discount, 19)
def test_combo_product_discount(self):
"""Ensure that pricelist discounts for combo products get applied to combo items"""
order = self.empty_order
product_a = self._create_product(name="Beefy burger")
product_b = self._create_product(name="Belgian fries")
combos = self.env['product.combo'].create([{
'name': "Burger",
'combo_item_ids': [Command.create({'product_id': product_a.id})],
}, {
'name': "Side",
'combo_item_ids': [Command.create({'product_id': product_b.id})],
}])
product_combo = self._create_product(
name="Meal Menu",
list_price=10.0,
type='combo',
combo_ids=[Command.set(combos.ids)],
)
self._create_discount_pricelist_rule(product_tmpl_id=product_combo.product_tmpl_id.id)
combo_line = self.env['sale.order.line'].create({
'order_id': order.id,
'product_id': product_combo.id,
})
item_lines = self.env['sale.order.line'].create([{
'order_id': order.id,
'product_id': product.id,
'combo_item_id': combo.combo_item_ids.id,
'linked_line_id': combo_line.id,
} for product, combo in zip(product_a + product_b, combos)])
self.assertEqual(
item_lines.mapped('discount'),
[self.discount, self.discount],
"Discount should apply to combo item lines",
)
self.assertAlmostEqual(
order.amount_untaxed,
order.amount_undiscounted * (100 - self.discount) / 100,
msg="Pricelist discount should be applied to quotation",
)

View file

@ -1,96 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields
from odoo.fields import Command
from odoo.tests import tagged
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.product.tests.test_product_attribute_value_config import TestProductAttributeValueCommon
from odoo.addons.product.tests.common import ProductAttributesCommon
from odoo.addons.sale.tests.common import SaleCommon
@tagged("post_install", "-at_install")
class TestSaleProductAttributeValueCommon(AccountTestInvoicingCommon, TestProductAttributeValueCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.computer.company_id = cls.env.company
cls.computer = cls.computer.with_env(cls.env)
@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,
})
from odoo.addons.product.tests.test_product_attribute_value_config import (
TestProductAttributeValueCommon,
)
@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)
class TestSaleProductAttributeValueConfig(TestProductAttributeValueCommon):
def test_01_is_combination_possible_archived(self):
"""The goal is to test the possibility of archived combinations.
@ -202,320 +120,3 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueCommon):
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)
self.assertEqual(res['price_extra'], 222)
# 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)
self.assertEqual(res['price_extra'], 222)
# 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)
self.assertEqual(res['price_extra'], 222 * 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)
self.assertEqual(res['price_extra'], 222 * 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)
self.assertEqual(res['price_extra'], 222 * 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)
self.assertEqual(res['price_extra'], (222 - 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)
self.assertEqual(res['price_extra'], (222 - 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['price_extra'], 222 * 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['price_extra'], 222 * 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['price_extra'], 222 * 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['price_extra'], 222 * 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
@tagged('post_install', '-at_install')
class TestSaleProductVariants(ProductAttributesCommon, SaleCommon):
# TODO move to sale_product_configurator tests in 16.3+
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.product_template_2lines_2attributes = cls.env['product.template'].create({
'name': '2 lines 2 attributes',
'uom_id': cls.uom_unit.id,
'uom_po_id': cls.uom_unit.id,
'categ_id': cls.product_category.id,
'attribute_line_ids': [
Command.create({
'attribute_id': cls.color_attribute.id,
'value_ids': [Command.set([
cls.color_attribute_red.id,
cls.color_attribute_blue.id,
])],
}),
Command.create({
'attribute_id': cls.size_attribute.id,
'value_ids': [Command.set([
cls.size_attribute_s.id,
cls.size_attribute_m.id,
])]
})
]
})
# Sell all variants
cls.empty_order.order_line = [
Command.create({
'product_id': product.id,
})
for product in cls.product_template_2lines_2attributes.product_variant_ids
]
def test_attribute_removal(self):
def _get_ptavs():
return self.product_template_2lines_2attributes.with_context(
active_test=False
).attribute_line_ids.product_template_value_ids
def _get_archived_variants():
return self.product_template_2lines_2attributes.with_context(
active_test=False
).product_variant_ids.filtered(lambda p: not p.active)
def _get_active_variants():
return self.product_template_2lines_2attributes.product_variant_ids
self.assertEqual(len(_get_ptavs()), 4)
self.product_template_2lines_2attributes.attribute_line_ids = [
Command.unlink(self.product_template_2lines_2attributes.attribute_line_ids.filtered(
lambda ptal: ptal.attribute_id.id == self.size_attribute.id
).id)
]
self.assertEqual(len(_get_ptavs()), 4)
# Use products s.t. they are archived and not deleted
self.empty_order.order_line = [
Command.create({
'product_id': product.id,
})
for product in self.product_template_2lines_2attributes.product_variant_ids
]
self.assertEqual(len(_get_archived_variants()), 4)
self.assertEqual(len(_get_active_variants()), 2)
self.product_template_2lines_2attributes.attribute_line_ids = [
Command.create({
'attribute_id': self.size_attribute.id,
'value_ids': [Command.set([
self.size_attribute_s.id,
])]
})
]
self.assertEqual(len(_get_ptavs()), 4)
self.assertEqual(len(_get_active_variants()), 2)
self.assertEqual(len(_get_archived_variants()), 4)
# When adding a single attribute line, the attribute will be added to all existing variants
# Instead of unarchiving existing archived variants with the same combination
# Leading to a state where the database holds two variants with the same combination
# We don't want this combination to be excluded from the product configurator as it is valid
# as long as there is one active variant with this configuration.
exclusions_data = self.product_template_2lines_2attributes._get_attribute_exclusions()
self.assertTrue(
all(
tuple(product.product_template_attribute_value_ids.ids) not in exclusions_data['archived_combinations']
for product in _get_active_variants()
)
)

View file

@ -0,0 +1,34 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo.tests import tagged, users
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestSaleProductTemplate(SaleCommon):
@users('salesman')
def test_sale_get_configurator_display_price(self):
configurator_price = self.env['product.template']._get_configurator_display_price(
product_or_template=self._create_product(list_price=40),
quantity=3,
date=datetime(2000, 1, 1),
currency=self.currency,
pricelist=self.pricelist,
)
self.assertEqual(configurator_price[0], 40)
@users('salesman')
def test_sale_get_additional_configurator_data(self):
configurator_data = self.env['product.template']._get_additional_configurator_data(
product_or_template=self.product,
date=datetime(2000, 1, 1),
currency=self.currency,
pricelist=self.pricelist,
)
self.assertEqual(configurator_data, {})

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
@ -11,35 +10,34 @@ from odoo.addons.sale.tests.common import TestSaleCommon
class TestSaleRefund(TestSaleCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
# Create the SO with four order lines
cls.sale_order = cls.env['sale.order'].with_context(tracking_disable=True).create({
cls.sale_order = cls.env['sale.order'].create({
'partner_id': cls.partner_a.id,
'partner_invoice_id': cls.partner_a.id,
'partner_shipping_id': cls.partner_a.id,
'pricelist_id': cls.company_data['default_pricelist'].id,
'order_line': [
Command.create({
'product_id': cls.company_data['product_order_no'].id,
'product_uom_qty': 5,
'tax_id': False,
'tax_ids': False,
}),
Command.create({
'product_id': cls.company_data['product_service_delivery'].id,
'product_uom_qty': 4,
'tax_id': False,
'tax_ids': False,
}),
Command.create({
'product_id': cls.company_data['product_service_order'].id,
'product_uom_qty': 3,
'tax_id': False,
'tax_ids': False,
}),
Command.create({
'product_id': cls.company_data['product_delivery_no'].id,
'product_uom_qty': 2,
'tax_id': False,
'tax_ids': False,
}),
]
})
@ -92,11 +90,10 @@ class TestSaleRefund(TestSaleCommon):
# 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',
'journal_id': self.invoice.journal_id.id,
})
credit_note_wizard.reverse_moves()
credit_note_wizard.refund_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
@ -153,67 +150,6 @@ class TestSaleRefund(TestSaleCommon):
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',
'journal_id': self.invoice.journal_id.id,
})
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
@ -247,11 +183,10 @@ class TestSaleRefund(TestSaleCommon):
# 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',
'journal_id': self.invoice.journal_id.id,
})
invoice_refund = self.env['account.move'].browse(credit_note_wizard.reverse_moves()['res_id'])
invoice_refund = self.env['account.move'].browse(credit_note_wizard.modify_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')
@ -320,13 +255,12 @@ class TestSaleRefund(TestSaleCommon):
'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,
})
sol_product = self.env['sale.order.line'].create({
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 5,
'order_id': sale_order_refund.id,
'tax_id': False,
'tax_ids': False,
})
self.assertRecordValues(sol_product, [{
@ -352,7 +286,6 @@ class TestSaleRefund(TestSaleCommon):
downpayment = self.env['sale.advance.payment.inv'].with_context(so_context).create({
'advance_payment_method': 'percentage',
'amount': 50,
'deposit_account_id': self.company_data['default_account_revenue'].id
})
downpayment.create_invoices()
# order_line[1] is the down payment section
@ -371,9 +304,7 @@ class TestSaleRefund(TestSaleCommon):
'qty_to_invoice': -1.0,
}])
payment = self.env['sale.advance.payment.inv'].with_context(so_context).create({
'deposit_account_id': self.company_data['default_account_revenue'].id
})
payment = self.env['sale.advance.payment.inv'].with_context(so_context).create({})
payment.create_invoices()
so_invoice = max(sale_order_refund.invoice_ids)
@ -384,11 +315,10 @@ class TestSaleRefund(TestSaleCommon):
so_invoice.action_post()
credit_note_wizard = self.env['account.move.reversal'].with_context({'active_ids': [so_invoice.id], 'active_id': so_invoice.id, 'active_model': 'account.move'}).create({
'refund_method': 'refund',
'reason': 'reason test refund with downpayment',
'journal_id': so_invoice.journal_id.id,
})
credit_note_wizard.reverse_moves()
credit_note_wizard.refund_moves()
invoice_refund = sale_order_refund.invoice_ids.sorted(key=lambda inv: inv.id, reverse=False)[-1]
invoice_refund.action_post()

View file

@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields
from odoo.fields import Command
from odoo.tests import tagged
from odoo.tools.float_utils import float_compare
from odoo.addons.sale.tests.common import SaleCommon
@ -40,9 +40,9 @@ class TestSaleReportCurrencyRate(SaleCommon):
# Create corresponding pricelists and rates.
pricelists = self.env['product.pricelist'].create([
{'name': 'Pricelist (USD)', 'currency_id': usd.id},
{'name': 'Pricelist (EUR)', 'currency_id': eur.id},
{'name': 'Pricelist (ARS)', 'currency_id': ars.id},
{'name': 'Pricelist (USD)', 'currency_id': usd.id, 'company_id': False},
{'name': 'Pricelist (EUR)', 'currency_id': eur.id, 'company_id': False},
{'name': 'Pricelist (ARS)', 'currency_id': ars.id, 'company_id': False},
])
self.env['res.currency.rate'].create([
{'name': past_day, 'rate': 555, 'currency_id': ars.id, 'company_id': self.eur_cmp.id},
@ -101,11 +101,8 @@ class TestSaleReportCurrencyRate(SaleCommon):
# to the currency of the so company and then from it to the currency of the so
# pricelist.
price_for_so_company = self.product.list_price / expected_product_currency_rate
expected_rounded_price = pricelist.currency_id.round(
price_for_so_company * expected_so_currency_rate
)
expected_amount_total = qty * expected_rounded_price
expected_amount_total = pricelist.currency_id.round(qty * price_for_so_company * expected_so_currency_rate)
self.assertAlmostEqual(order.currency_rate, expected_so_currency_rate)
self.assertAlmostEqual(order.amount_total, expected_amount_total)
@ -121,7 +118,33 @@ class TestSaleReportCurrencyRate(SaleCommon):
# The report should show the amount in the current (in this case usd) company currency.
report_lines = self.env['sale.report'].sudo().with_context(
allow_company_ids=[self.usd_cmp.id, self.eur_cmp.id]
).search([('order_id', 'in', sale_orders.ids)])
).search([('order_reference', 'in', [f'sale.order,{so_id}' for so_id in sale_orders.ids])])
price_total = sum(report_lines.mapped('price_total'))
self.assertEqual(price_total, expected_reported_amount)
self.assertAlmostEqual(price_total, expected_reported_amount)
def test_sale_report_with_downpayment(self):
"""Checks that downpayment lines are used in the calculation of amounts invoiced and to invoice"""
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [Command.create({
'product_id': self.product.id,
})]
})
order.action_confirm()
downpayment = self.env['sale.advance.payment.inv'].with_context(active_ids=order.ids).create({
'advance_payment_method': 'fixed',
'fixed_amount': 200
})
downpayment.create_invoices()
order.invoice_ids.action_post()
order.order_line.flush_recordset()
amount_line = self.env['sale.report'].formatted_read_group(
[('order_reference', '=', f'sale.order,{order.id}')],
aggregates=['untaxed_amount_to_invoice:sum', 'untaxed_amount_invoiced:sum'],
)[0]
self.assertEqual(float_compare(amount_line['untaxed_amount_invoiced:sum'], 200, precision_rounding=order.currency_id.rounding), 0)
self.assertEqual(float_compare(amount_line['untaxed_amount_to_invoice:sum'], self.product.lst_price - 200, precision_rounding=order.currency_id.rounding), 0)

View file

@ -0,0 +1,142 @@
from odoo.fields import Command
from odoo.tests import tagged
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestSaleSections(SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.tax_1 = cls.env['account.tax'].create({
'name': 'Tax 1',
'amount': 10,
})
cls.tax_2 = cls.env['account.tax'].create({
'name': 'Tax 2',
'amount': 20,
})
cls.sections_sale_order = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'order_line': [
Command.create({
'name': 'r1',
'product_id': cls.product.id,
'price_unit': 10,
}),
Command.create({
'name': 'Sec1',
'display_type': 'line_section',
}),
Command.create({
'name': 'Sec1-r1',
'product_id': cls.product.id,
'tax_ids': cls.tax_2.ids,
'price_unit': 200,
}),
Command.create({
'name': 'Sec1-Sub1',
'display_type': 'line_subsection',
'collapse_composition': True,
}),
Command.create({
'name': 'Sec1-Sub1-r1',
'product_id': cls.product.id,
'tax_ids': (cls.tax_1 + cls.tax_2).ids,
'price_unit': 300,
}),
Command.create({
'name': 'Sec1-Sub1-r2',
'product_id': cls.product.id,
'tax_ids': (cls.tax_1 + cls.tax_2).ids,
'price_unit': 300,
}),
Command.create({
'name': 'Sec1-Sub1-r3',
'product_id': cls.product.id,
'tax_ids': cls.tax_1.ids,
'price_unit': 100,
}),
Command.create({
'name': 'Sec1-Sub2',
'display_type': 'line_subsection',
'collapse_composition': True,
}),
Command.create({
'name': 'Sec1-Sub2-r1',
'product_id': cls.product.id,
'tax_ids': cls.tax_2.ids,
'price_unit': 200,
}),
Command.create({
'name': 'Sec1-Sub2-r2',
'product_id': cls.product.id,
'tax_ids': cls.tax_1.ids,
'price_unit': 100,
}),
],
})
def test_sale_order_line_parent_id(self):
"""Verify correct assignment of `parent_id`:
- Lines with no preceding section/subsection no parent.
- Section's children (lines + subsections) → parent is the section.
- Subsection's children → parent is the subsection.
"""
self.assertFalse(self.sections_sale_order.order_line[0].parent_id)
self.assertEqual(
self.sections_sale_order.order_line[2].parent_id,
self.sections_sale_order.order_line[1],
)
self.assertEqual(
self.sections_sale_order.order_line[3].parent_id,
self.sections_sale_order.order_line[1],
)
self.assertEqual(
self.sections_sale_order.order_line[4].parent_id,
self.sections_sale_order.order_line[3],
)
def test_sale_order_report_line_visibility_and_grouping(self):
"""Check report utils for sections.
- `_get_order_lines_to_report` must exclude children of collapsed sections/subsections,
but keep regular lines and headers in order.
- `_get_grouped_section_summary` must correctly aggregate totals by tax.
"""
lines_to_report = self.sections_sale_order._get_order_lines_to_report()
self.assertEqual(len(lines_to_report), 5)
self.assertEqual(
lines_to_report.mapped('name'),
['r1', 'Sec1', 'Sec1-r1', 'Sec1-Sub1', 'Sec1-Sub2'],
"Lines of hidden subsection shouldn't be visible in report",
)
subsection_summary_lines = lines_to_report[3]._get_grouped_section_summary()
self.assertEqual(len(subsection_summary_lines), 2)
self.assertEqual(subsection_summary_lines[0]['price_subtotal'], 600.00)
self.assertEqual(subsection_summary_lines[1]['price_subtotal'], 100.00)
def test_sale_order_sections_totals(self):
"""Ensure section totals are computed correctly.
A `line_section` should aggregate the subtotals of all following product
order lines that belong to it, including those under nested subsections.
Aggregation must stop when another section or subsection is encountered.
"""
self.assertEqual(
self.sections_sale_order.order_line[1]._get_section_totals('price_subtotal'),
sum(self.sections_sale_order.order_line[1:].mapped('price_subtotal')),
)
self.assertEqual(
self.sections_sale_order.order_line[3]._get_section_totals('price_subtotal'),
sum(self.sections_sale_order.order_line[4:7].mapped('price_subtotal')),
)
self.assertEqual(
self.sections_sale_order.order_line[7]._get_section_totals('price_subtotal'),
sum(self.sections_sale_order.order_line[8:].mapped('price_subtotal')),
)

View file

@ -1,35 +0,0 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.test_invoice_tax_totals import TestTaxTotals
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class SaleTestTaxTotals(TestTaxTotals):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.so_product = cls.env['product.product'].create({
'name': 'Odoo course',
'type': 'service',
})
def _create_document_for_tax_totals_test(self, lines_data):
# Overridden in order to run the inherited tests with sale.order's
# tax_totals field instead of account.move's
lines_vals = [
(0, 0, {
'name': 'test',
'product_id': self.so_product.id,
'price_unit': amount,
'product_uom_qty': 1,
'tax_id': [(6, 0, taxes.ids)],
})
for amount, taxes in lines_data]
return self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': lines_vals,
})

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,202 @@
from odoo import Command
from odoo.exceptions import ValidationError
from odoo.tests import tagged
from odoo.addons.account.tests.test_taxes_global_discount import TestTaxesGlobalDiscount
from odoo.addons.sale.tests.common import TestTaxCommonSale
@tagged('post_install', '-at_install')
class TestTaxesGlobalDiscountSale(TestTaxCommonSale, TestTaxesGlobalDiscount):
# -------------------------------------------------------------------------
# HELPERS
# -------------------------------------------------------------------------
def assert_sale_order_global_discount(
self,
sale_order,
amount_type,
amount,
expected_values,
soft_checking=False,
):
""" Assert the expected values for the tax totals summary of the sale order
passed as parameter after the applicability of a global discount.
:param sale_order: The SO as a sale.order record.
:param amount_type: The type of the global discount: 'percent' or 'fixed'.
:param amount: The amount to consider.
For 'percent', it should be a percentage [0-100].
For 'fixed', any amount.
:param expected_values: The expected values for the tax_total_summary.
:param soft_checking: Limit the asserted values to the ones in 'expected_results'
and don't go deeper inside the tax_total_summary.
It allows to assert only the totals without asserting all the
tax details.
"""
if amount_type == 'percent':
discount_type = 'so_discount'
discount_percentage = amount / 100.0
discount_amount = None
else: # amount_type == 'fixed'
discount_type = 'amount'
discount_percentage = None
discount_amount = amount
discount_wizard = (
self.env['sale.order.discount']
.with_context({'active_model': sale_order._name, 'active_id': sale_order.id})
.create({
'discount_type': discount_type,
'discount_percentage': discount_percentage,
'discount_amount': discount_amount,
})
)
discount_wizard.action_apply_discount()
self._assert_tax_totals_summary(
sale_order.tax_totals,
expected_values,
soft_checking=soft_checking,
)
# -------------------------------------------------------------------------
# GENERIC TAXES TEST SUITE
# -------------------------------------------------------------------------
def test_taxes_l10n_in_sale_orders(self):
for test_mode, document, soft_checking, amount_type, amount, expected_values in self._test_taxes_l10n_in():
with self.subTest(test_code=test_mode, amount=amount):
sale_order = self.convert_document_to_sale_order(document)
sale_order.action_confirm()
self.assert_sale_order_global_discount(sale_order, amount_type, amount, expected_values, soft_checking=soft_checking)
def test_taxes_l10n_br_sale_orders(self):
for test_mode, document, soft_checking, amount_type, amount, expected_values in self._test_taxes_l10n_br():
with self.subTest(test_code=test_mode, amount=amount):
sale_order = self.convert_document_to_sale_order(document)
sale_order.action_confirm()
self.assert_sale_order_global_discount(sale_order, amount_type, amount, expected_values, soft_checking=soft_checking)
def test_taxes_l10n_be_sale_orders(self):
for test_mode, document, soft_checking, amount_type, amount, expected_values in self._test_taxes_l10n_be():
with self.subTest(test_code=test_mode, amount=amount):
sale_order = self.convert_document_to_sale_order(document)
sale_order.action_confirm()
self.assert_sale_order_global_discount(sale_order, amount_type, amount, expected_values, soft_checking=soft_checking)
# -------------------------------------------------------------------------
# SPECIFIC TESTS
# -------------------------------------------------------------------------
def test_global_discount_with_sol_discount(self):
product = self.company_data['product_order_cost']
so = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': [
# Standalone line with a discount already:
Command.create({
'name': 'line_1',
'product_id': product.id,
'price_unit': 1000.0,
'discount': 50.0,
}),
# Standalone line without any discount:
Command.create({
'name': 'line_2',
'product_id': product.id,
'price_unit': 2000.0,
}),
],
})
so.action_confirm()
self.assertRecordValues(so, [{
'amount_untaxed': 2500.0,
'amount_tax': 0.0,
'amount_total': 2500.0,
}])
# Put a discount of 30% on all SO lines.
wizard = self.env['sale.order.discount'].create({
'sale_order_id': so.id,
'discount_type': 'sol_discount',
'discount_percentage': 0.3,
})
wizard.action_apply_discount()
self.assertRecordValues(so.order_line, [
{'name': 'line_1', 'discount': 30.0},
{'name': 'line_2', 'discount': 30.0},
])
self.assertRecordValues(so, [{
'amount_untaxed': 2100.0,
'amount_tax': 0.0,
'amount_total': 2100.0,
}])
# Use the same wizard to clear the discount.
wizard.discount_percentage = 0.0
wizard.action_apply_discount()
self.assertRecordValues(so.order_line, [
{'name': 'line_1', 'discount': 0.0},
{'name': 'line_2', 'discount': 0.0},
])
self.assertRecordValues(so, [{
'amount_untaxed': 3000.0,
'amount_tax': 0.0,
'amount_total': 3000.0,
}])
# Try to put a percentage higher than 100%.
with self.assertRaises(ValidationError):
wizard.discount_percentage = 110.0
def test_cumulative_global_discounts(self):
product = self.company_data['product_order_cost']
so = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': [Command.create({
'name': 'line_1',
'product_id': product.id,
'price_unit': 2000.0,
})],
})
so.action_confirm()
self.assertRecordValues(so, [{
'amount_untaxed': 2000.0,
'amount_tax': 0.0,
'amount_total': 2000.0,
}])
# Put a discount of 25%.
wizard = self.env['sale.order.discount'].create({
'sale_order_id': so.id,
'discount_type': 'so_discount',
'discount_percentage': 0.25,
})
wizard.action_apply_discount()
self.assertRecordValues(so.order_line, [
{'price_unit': 2000.0},
{'price_unit': -500.0},
])
self.assertRecordValues(so, [{
'amount_untaxed': 1500.0,
'amount_tax': 0.0,
'amount_total': 1500.0,
}])
# Put another discount of 10%.
wizard.discount_percentage = 0.10
wizard.action_apply_discount()
self.assertRecordValues(so.order_line, [
{'price_unit': 2000.0},
{'price_unit': -500.0},
{'price_unit': -150.0},
])
self.assertRecordValues(so, [{
'amount_untaxed': 1350.0,
'amount_tax': 0.0,
'amount_total': 1350.0,
}])

View file

@ -0,0 +1,215 @@
from odoo.addons.account.tests.test_taxes_tax_totals_summary import TestTaxesTaxTotalsSummary
from odoo.addons.sale.tests.common import TestTaxCommonSale
from odoo.fields import Command
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestTaxesTaxTotalsSummarySale(TestTaxCommonSale, TestTaxesTaxTotalsSummary):
def test_taxes_l10n_in_sale_orders(self):
for test_index, document, expected_values in self._test_taxes_l10n_in():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_taxes_l10n_br_sale_orders(self):
for test_index, document, expected_values in self._test_taxes_l10n_br():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_taxes_l10n_be_sale_orders(self):
for test_index, document, expected_values in self._test_taxes_l10n_be():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_taxes_l10n_mx_sale_orders(self):
for test_index, document, expected_values in self._test_taxes_l10n_mx():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_reverse_charge_taxes_1_generic_helpers(self):
for document, expected_values in self._test_reverse_charge_taxes_1():
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_reverse_charge_taxes_2_generic_helpers(self):
for document, expected_values in self._test_reverse_charge_taxes_2():
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_mixed_combined_standalone_taxes_sale_orders(self):
for test_index, document, expected_values in self._test_mixed_combined_standalone_taxes():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_preceding_subtotal_sale_orders(self):
for test_index, document, expected_values in self._test_preceding_subtotal():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_preceding_subtotal_with_tax_group_sale_orders(self):
for test_index, document, expected_values in self._test_preceding_subtotal_with_tax_group():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_preceding_subtotal_with_include_base_amount_sale_orders(self):
document, expected_values = self._test_preceding_subtotal_with_include_base_amount()
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_reverse_charge_percent_tax_sale_orders(self):
for test_index, document, expected_values in self._test_reverse_charge_percent_tax():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
self.assertRecordValues(sale_order.order_line, [{
'price_subtotal': expected_values['total_amount_currency'],
'price_total': expected_values['total_amount_currency'],
}])
def test_reverse_charge_division_tax_sale_orders(self):
for test_index, document, expected_values in self._test_reverse_charge_division_tax():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
self.assertRecordValues(sale_order.order_line, [{
'price_subtotal': expected_values['total_amount_currency'],
'price_total': expected_values['total_amount_currency'],
}])
def test_discount_with_round_globally_sale_orders(self):
for test_index, document, expected_values in self._test_discount_with_round_globally():
with self.subTest(test_index=test_index):
sale_order = self.convert_document_to_sale_order(document)
self.assert_sale_order_tax_totals_summary(sale_order, expected_values)
def test_apply_mixed_epd_discount(self):
"""
When applying an epd - mixed payment term, the tax should be computed based on the discounted untaxed amount.
"""
tax_a = self.percent_tax(15.0)
early_payment_term = self.env['account.payment.term'].create({
'name': "early_payment_term",
'early_pay_discount_computation': 'mixed',
'discount_percentage': 10,
'discount_days': 10,
'early_discount': True,
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 100,
'nb_days': 20,
}),
],
})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'payment_term_id': early_payment_term.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'price_unit': 100,
'tax_ids': [Command.set(tax_a.ids)],
}),
],
})
self.assert_sale_order_tax_totals_summary(
sale_order,
{
'base_amount_currency': 100.0,
'tax_amount_currency': 13.5,
'total_amount_currency': 113.5,
},
soft_checking=True,
)
def test_apply_mixed_epd_discount_fixed_tax(self):
"""
When applying an epd - mixed payment term, the fixed tax amount should be the same.
"""
tax_a = self.fixed_tax(20.0)
early_payment_term = self.env['account.payment.term'].create({
'name': "early_payment_term",
'early_pay_discount_computation': 'mixed',
'discount_percentage': 10,
'discount_days': 10,
'early_discount': True,
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 100,
'nb_days': 20,
}),
],
})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'payment_term_id': early_payment_term.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'price_unit': 100,
'tax_ids': [Command.set(tax_a.ids)],
}),
],
})
self.assert_sale_order_tax_totals_summary(
sale_order,
{
'base_amount_currency': 100.0,
'tax_amount_currency': 20.0,
'total_amount_currency': 120.0,
},
soft_checking=True,
)
def test_apply_mixed_epd_discount_percent_and_fixed_tax(self):
"""
When applying an epd - mixed payment term, the percent tax should be computed based on the discounted untaxed amount.
"""
tax_a = self.percent_tax(15.0)
tax_b = self.fixed_tax(20.0)
early_payment_term = self.env['account.payment.term'].create({
'name': "early_payment_term",
'early_pay_discount_computation': 'mixed',
'discount_percentage': 10,
'discount_days': 10,
'early_discount': True,
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 100,
'nb_days': 20,
}),
],
})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'payment_term_id': early_payment_term.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'price_unit': 100,
'tax_ids': [Command.set((tax_a + tax_b).ids)],
}),
],
})
self.assert_sale_order_tax_totals_summary(
sale_order,
{
'base_amount_currency': 100.0,
'tax_amount_currency': 33.5,
'total_amount_currency': 133.5,
},
soft_checking=True,
)