Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_access_rights
from . import test_accrued_sale_orders
from . import test_common
from . import test_controllers
from . import test_credit_limit
from . import test_onchange
from . import test_payment_flow
from . import test_reinvoice
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_prices
from . import test_sale_product_attribute_value_config
from . import test_sale_refund
from . import test_sale_tax_totals
from . import test_sale_to_invoice
from . import test_sale_report

View file

@ -0,0 +1,240 @@
# -*- 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.product.tests.common import ProductCommon
from odoo.addons.sales_team.tests.common import SalesTeamCommon
class SaleCommon(
ProductCommon, # BaseCommon, UomCommon
SalesTeamCommon,
):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.company.country_id = cls.env.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.empty_order = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
})
cls.sale_order = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'order_line': [
Command.create({
'product_id': cls.consumable_product.id,
'product_uom_qty': 5.0,
}),
Command.create({
'product_id': cls.service_product.id,
'product_uom_qty': 12.5,
})
]
})
@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,
}),
# Users
'default_user_salesman': Users.create({
'name': 'default_user_salesman',
'login': 'default_user_salesman.comp%s' % company.id,
'email': 'default_user_salesman@example.com',
'signature': '--\nMark',
'notification_type': 'email',
'groups_id': [(6, 0, cls.env.ref('sales_team.group_sale_salesman').ids)],
'company_ids': [(6, 0, company.ids)],
'company_id': company.id,
}),
'default_user_portal': Users.create({
'name': 'default_user_portal',
'login': 'default_user_portal.comp%s' % company.id,
'email': 'default_user_portal@gladys.portal',
'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])],
'company_ids': [(6, 0, company.ids)],
'company_id': company.id,
}),
'default_user_employee': Users.create({
'name': 'default_user_employee',
'login': 'default_user_employee.comp%s' % company.id,
'email': 'default_user_employee@example.com',
'groups_id': [(6, 0, [cls.env.ref('base.group_user').id])],
'company_ids': [(6, 0, company.ids)],
'company_id': company.id,
}),
# Pricelist
'default_pricelist': cls.env['product.pricelist'].with_company(company).create({
'name': 'default_pricelist',
'currency_id': company.currency_id.id,
}),
# Product category
'product_category': cls.env['product.category'].with_company(company).create({
'name': 'Test category',
}),
}
company_data.update({
# Products
'product_service_delivery': cls.env['product.product'].with_company(company).create({
'name': 'product_service_delivery',
'categ_id': company_data['product_category'].id,
'standard_price': 200.0,
'list_price': 180.0,
'type': 'service',
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
'default_code': 'SERV_DEL',
'invoice_policy': 'delivery',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
}),
'product_service_order': cls.env['product.product'].with_company(company).create({
'name': 'product_service_order',
'categ_id': company_data['product_category'].id,
'standard_price': 40.0,
'list_price': 90.0,
'type': 'service',
'uom_id': cls.env.ref('uom.product_uom_hour').id,
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
'description': 'Example of product to invoice on order',
'default_code': 'PRE-PAID',
'invoice_policy': 'order',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
}),
'product_order_cost': cls.env['product.product'].with_company(company).create({
'name': 'product_order_cost',
'categ_id': company_data['product_category'].id,
'standard_price': 235.0,
'list_price': 280.0,
'type': 'consu',
'weight': 0.01,
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
'default_code': 'FURN_9999',
'invoice_policy': 'order',
'expense_policy': 'cost',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
}),
'product_delivery_cost': cls.env['product.product'].with_company(company).create({
'name': 'product_delivery_cost',
'categ_id': company_data['product_category'].id,
'standard_price': 55.0,
'list_price': 70.0,
'type': 'consu',
'weight': 0.01,
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
'default_code': 'FURN_7777',
'invoice_policy': 'delivery',
'expense_policy': 'cost',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
}),
'product_order_sales_price': cls.env['product.product'].with_company(company).create({
'name': 'product_order_sales_price',
'categ_id': company_data['product_category'].id,
'standard_price': 235.0,
'list_price': 280.0,
'type': 'consu',
'weight': 0.01,
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
'default_code': 'FURN_9999',
'invoice_policy': 'order',
'expense_policy': 'sales_price',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
}),
'product_delivery_sales_price': cls.env['product.product'].with_company(company).create({
'name': 'product_delivery_sales_price',
'categ_id': company_data['product_category'].id,
'standard_price': 55.0,
'list_price': 70.0,
'type': 'consu',
'weight': 0.01,
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
'default_code': 'FURN_7777',
'invoice_policy': 'delivery',
'expense_policy': 'sales_price',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
}),
'product_order_no': cls.env['product.product'].with_company(company).create({
'name': 'product_order_no',
'categ_id': company_data['product_category'].id,
'standard_price': 235.0,
'list_price': 280.0,
'type': 'consu',
'weight': 0.01,
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
'default_code': 'FURN_9999',
'invoice_policy': 'order',
'expense_policy': 'no',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
}),
'product_delivery_no': cls.env['product.product'].with_company(company).create({
'name': 'product_delivery_no',
'categ_id': company_data['product_category'].id,
'standard_price': 55.0,
'list_price': 70.0,
'type': 'consu',
'weight': 0.01,
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'uom_po_id': cls.env.ref('uom.product_uom_unit').id,
'default_code': 'FURN_7777',
'invoice_policy': 'delivery',
'expense_policy': 'no',
'taxes_id': [(6, 0, [])],
'supplier_taxes_id': [(6, 0, [])],
}),
})
return company_data
class TestSaleCommon(AccountTestInvoicingCommon, TestSaleCommonBase):
''' Setup to be used post-install with sale and accounting test configuration.'''
@classmethod
def setup_company_data(cls, company_name, chart_template=None, **kwargs):
company_data = super().setup_company_data(company_name, chart_template=chart_template, **kwargs)
company_data.update(cls.setup_sale_configuration_for_company(company_data['company']))
company_data['product_category'].write({
'property_account_income_categ_id': company_data['default_account_revenue'].id,
'property_account_expense_categ_id': company_data['default_account_expense'].id,
})
return company_data

View file

@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestAccessRights(BaseUsersCommon, SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
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)],
})
# Create the SO with a specific salesperson
cls.sale_order.user_id = cls.sale_user
def test_access_sales_manager(self):
""" Test sales manager's access rights """
SaleOrder = self.env['sale.order'].with_user(self.sale_manager)
so_as_sale_manager = SaleOrder.browse(self.sale_order.id)
# Manager can see the SO which is assigned to another salesperson
so_as_sale_manager.read()
# Manager can change a salesperson of the SO
so_as_sale_manager.write({'user_id': self.sale_user2.id})
# Manager can create the SO for other salesperson
sale_order = SaleOrder.create({
'partner_id': self.partner.id,
'user_id': self.sale_user.id
})
self.assertIn(
sale_order.id, SaleOrder.search([]).ids,
'Sales manager should be able to create the SO of other salesperson')
# Manager can confirm the SO
sale_order.action_confirm()
# Manager can not delete confirmed SO
with self.assertRaises(UserError), mute_logger('odoo.models.unlink'):
sale_order.unlink()
# Manager can delete the SO of other salesperson if SO is in 'draft' or 'cancel' state
so_as_sale_manager.unlink()
self.assertNotIn(
so_as_sale_manager.id, SaleOrder.search([]).ids,
'Sales manager should be able to delete the SO')
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
def test_access_sales_person(self):
""" Test Salesperson's access rights """
SaleOrder = self.env['sale.order'].with_user(self.sale_user2)
so_as_salesperson = SaleOrder.browse(self.sale_order.id)
# Salesperson can see only their own sales order
with self.assertRaises(AccessError):
so_as_salesperson.read()
# Now assign the SO to themselves
# (using self.sale_order to do the change as superuser)
self.sale_order.write({'user_id': self.sale_user2.id})
# The salesperson is now able to read it
so_as_salesperson.read()
# Salesperson can change a Sales Team of SO
so_as_salesperson.write({'team_id': self.sale_team.id})
# Salesperson can't create a SO for other salesperson
with self.assertRaises(AccessError):
self.env['sale.order'].with_user(self.sale_user2).create({
'partner_id': self.partner.id,
'user_id': self.sale_user.id
})
# Salesperson can't delete Sale Orders
with self.assertRaises(AccessError):
so_as_salesperson.unlink()
# Salesperson can confirm the SO
so_as_salesperson.action_confirm()
@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 """
SaleOrder = self.env['sale.order'].with_user(self.user_portal)
so_as_portal_user = SaleOrder.browse(self.sale_order.id)
# Portal user can see the confirmed SO for which they are assigned as a customer
with self.assertRaises(AccessError):
so_as_portal_user.read()
self.sale_order.partner_id = self.user_portal.partner_id
self.sale_order.action_confirm()
# Portal user can't edit the SO
with self.assertRaises(AccessError):
so_as_portal_user.write({'team_id': self.sale_team.id})
# Portal user can't create the SO
with self.assertRaises(AccessError):
SaleOrder.create({
'partner_id': self.partner.id,
})
# Portal user can't delete the SO which is in 'draft' or 'cancel' state
self.sale_order.action_cancel()
with self.assertRaises(AccessError):
so_as_portal_user.unlink()
@mute_logger('odoo.addons.base.models.ir_model')
def test_access_employee(self):
""" Test classic employee's access rights """
SaleOrder = self.env['sale.order'].with_user(self.user_internal)
so_as_internal_user = SaleOrder.browse(self.sale_order.id)
# Employee can't see any SO
with self.assertRaises(AccessError):
so_as_internal_user.read()
# Employee can't edit the SO
with self.assertRaises(AccessError):
so_as_internal_user.write({'team_id': self.sale_team.id})
# Employee can't create the SO
with self.assertRaises(AccessError):
SaleOrder.create({
'partner_id': self.partner.id,
})
# Employee can't delete the SO
with self.assertRaises(AccessError):
so_as_internal_user.unlink()

View file

@ -0,0 +1,138 @@
# -*- 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
from odoo.exceptions import UserError
@freeze_time('2022-01-01')
@tagged('post_install', '-at_install')
class TestAccruedSaleOrders(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
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
cls.product_a.update({
'type': 'service',
'invoice_policy': 'delivery',
})
cls.product_b.update({
'type': 'service',
'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.analytic_account_a = cls.env['account.analytic.account'].create({
'name': 'analytic_account_a',
'plan_id': cls.default_plan.id,
'company_id': False,
})
cls.analytic_account_b = cls.env['account.analytic.account'].create({
'name': 'analytic_account_b',
'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': [
Command.create({
'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,
'analytic_distribution': {
cls.analytic_account_a.id : 80.0,
cls.analytic_account_b.id : 20.0,
},
}),
Command.create({
'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,
'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']
cls.wizard = cls.env['account.accrued.orders.wizard'].with_context({
'active_model': 'sale.order',
'active_ids': cls.sale_order.ids,
}).create({
'account_id': cls.account_expense.id,
})
def test_accrued_order(self):
# 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, [
# 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},
])
# delivered products invoiced, nothing to invoice left
self.sale_order.with_context(default_invoice_date=self.wizard.date)._create_invoices().action_post()
with self.assertRaises(UserError):
self.env['account.move.line']._invalidate_cache()
self.wizard.create_entries()
self.assertTrue(self.wizard.display_amount)
def test_multi_currency_accrued_order(self):
# 5 qty of each product billeable
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.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},
{'account_id': self.alt_inc_account.id, 'debit': 1000 / 2, 'credit': 0, 'amount_currency': 1000},
{'account_id': self.account_expense.id, 'debit': 0, 'credit': 6000 / 2, 'amount_currency': 0.0},
# move lines
{'account_id': self.account_revenue.id, 'debit': 0, 'credit': 5000 / 2, 'amount_currency': -5000},
{'account_id': self.alt_inc_account.id, 'debit': 0, 'credit': 1000 / 2, 'amount_currency': -1000},
{'account_id': self.account_expense.id, 'debit': 6000 / 2, 'credit': 0, 'amount_currency': 0.0},
])
def test_analytic_account_accrued_order(self):
self.sale_order.order_line.qty_delivered = 10
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}},
# 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}},
])

View file

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestSaleCommon(SaleCommon):
def test_common(self):
self.assertFalse(self.empty_order.order_line)
self.assertEqual(self.empty_order.amount_total, 0.0)
self.assertEqual(self.empty_order.partner_id, self.partner)
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.team_id, self.sale_team)
self.assertEqual(self.empty_order.state, 'draft')
self.assertEqual(self.sale_order.partner_id, self.partner)
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.team_id, self.sale_team)
self.assertEqual(self.sale_order.state, 'draft')
consumable_line, service_line = self.sale_order.order_line
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.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.price_total, 12.5 * 50)
self.assertEqual(self.sale_order.amount_total, 725.0)

View file

@ -0,0 +1,110 @@
# -*- 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.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestAccessRightsControllers(BaseUsersCommon, HttpCase, SaleCommon):
@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)
portal_so._portal_ensure_token()
token = portal_so.access_token
self.authenticate(None, None)
# Test public user can't print an order without a token
req = self.url_open(
url='/my/orders/%s?report_type=pdf' % portal_so.id,
allow_redirects=False,
)
self.assertEqual(req.status_code, 303)
# or with a random token
req = self.url_open(
url='/my/orders/%s?access_token=%s&report_type=pdf' % (
portal_so.id,
"foo",
),
allow_redirects=False,
)
self.assertEqual(req.status_code, 303)
# but works fine with the right token
req = self.url_open(
url='/my/orders/%s?access_token=%s&report_type=pdf' % (
portal_so.id,
token,
),
allow_redirects=False,
)
self.assertEqual(req.status_code, 200)
self.authenticate(self.user_portal.login, self.user_portal.login)
# do not need the token when logged in
req = self.url_open(
url='/my/orders/%s?report_type=pdf' % portal_so.id,
allow_redirects=False,
)
self.assertEqual(req.status_code, 200)
# but still can't access another order
req = self.url_open(
url='/my/orders/%s?report_type=pdf' % private_so.id,
allow_redirects=False,
)
self.assertEqual(req.status_code, 303)
@tagged('post_install', '-at_install')
class TestSalesControllers(BaseUsersCommon, HttpCase, SaleCommon):
def test_sales_portal_report(self):
portal_so = self.sale_order.copy()
portal_so.message_subscribe(self.user_portal.partner_id.ids)
self.authenticate(None, None)
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")
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")
@tagged('post_install', '-at_install')
class TestSaleSignature(HttpCaseWithUserPortal):
def test_01_portal_sale_signature_tour(self):
"""The goal of this test is to make sure the portal user can sign SO."""
portal_user_partner = self.partner_portal
# create a SO to be signed
sales_order = self.env['sale.order'].create({
'name': 'test SO',
'partner_id': portal_user_partner.id,
'state': 'sent',
'require_payment': False,
})
self.env['sale.order.line'].create({
'order_id': sales_order.id,
'product_id': self.env['product.product'].create({'name': 'A product'}).id,
})
# must be sent to the user so he can see it
email_act = sales_order.action_quotation_send()
email_ctx = email_act.get('context', {})
sales_order.with_context(**email_ctx).message_post_with_template(
email_ctx.get('default_template_id'))
self.start_tour("/", 'sale_signature', login="portal")

View file

@ -0,0 +1,101 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import Form, tagged, users
from .common import TestSaleCommon
@tagged('post_install', '-at_install')
class TestSaleOrderCreditLimit(TestSaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.company.account_use_credit_limit = True
buck_currency = cls.env['res.currency'].create({
'name': 'TB',
'symbol': 'TB',
})
cls.env['res.currency.rate'].create({
'name': '2023-01-01',
'rate': 2.0,
'currency_id': buck_currency.id,
'company_id': cls.env.company.id,
})
cls.buck_pricelist = cls.env['product.pricelist'].create({
'name': 'Test Buck Pricelist',
'currency_id': buck_currency.id,
})
cls.sales_user = cls.company_data['default_user_salesman']
cls.sales_user.write({
'login': "notaccountman",
'email': "bad@accounting.com",
})
cls.empty_order = cls.env['sale.order'].create({
'partner_id': cls.partner_a.id,
})
def test_credit_limit_multicurrency(self):
self.partner_a.credit_limit = 50
order = self.empty_order
order.write({
'pricelist_id': self.buck_pricelist.id,
'order_line': [
Command.create({
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 1,
'price_unit': 45.0,
'tax_id': False,
})
]
})
self.assertEqual(order.amount_total / order.currency_rate, 22.5)
self.assertEqual(order.partner_credit_warning, '')
order.write({
'order_line': [
Command.create({
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 1,
'price_unit': 65.0,
'tax_id': 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"
)
@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
for group in self.partner_a._fields['credit'].groups.split(','):
self.assertFalse(self.env.user.has_group(group))
with Form(self.empty_order.with_env(self.env)) as order_form:
with order_form.order_line.new() as sol:
sol.product_id = self.product_a
sol.tax_id.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)
self.assertTrue(
order_form.partner_credit_warning,
"Credit warning should be displayed",
)

View file

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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`.)
"""
company_a = self.env['res.company'].create({'name': 'Company A'})
company_b = self.env['res.company'].create({'name': 'Company B'})
products = self.env['product.template'].create([
{'name': "Product Test 1", 'company_id': company_a.id},
{'name': "Product Test 2", 'company_id': company_b.id},
{'name': "Product Test 3", 'company_id': False},
])
self.assertRecordValues(products, [
{'company_id': company_a.id},
{'company_id': company_b.id},
{'company_id': False},
])

View file

@ -0,0 +1,273 @@
# 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 odoo.tools import mute_logger
from odoo.addons.account_payment.tests.common import AccountPaymentCommon
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
from odoo.addons.sale.tests.common import SaleCommon
@tagged('-at_install', 'post_install')
class TestSalePayment(AccountPaymentCommon, SaleCommon, PaymentHttpCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Replace PaymentCommon defaults by SaleCommon ones
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
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)
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'],
})
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'])
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)
# 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_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
tx_context = self._get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['partner_id'], self.sale_order.partner_invoice_id.id)
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
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)
tx_sudo = self._get_tx(processing_values['reference'])
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_confirmed_transactions_comfirms_so_with_multiple_transaction(self):
""" Test that a confirmed transaction confirms a SO even if one or more non-confirmed
transactions are linked. """
# Create the payment
self.amount = self.sale_order.amount_total
self._create_transaction(
flow='redirect',
sale_order_ids=[self.sale_order.id],
state='draft',
reference='Test Transaction Draft 1',
)
self._create_transaction(
flow='redirect',
sale_order_ids=[self.sale_order.id],
state='draft',
reference='Test Transaction Draft 2',
)
tx = self._create_transaction(flow='redirect', sale_order_ids=[self.sale_order.id], state='done')
tx._reconcile_after_done()
self.assertEqual(self.sale_order.state, 'sale')
def test_auto_confirm_and_auto_invoice(self):
# 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'):
tx._reconcile_after_done()
self.assertEqual(self.sale_order.state, 'sale')
self.assertTrue(tx.invoice_ids)
self.assertTrue(self.sale_order.invoice_ids)
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')
# 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()
self.assertEqual(self.sale_order.state, 'done')
self.assertTrue(tx.invoice_ids)
self.assertTrue(self.sale_order.invoice_ids)
self.assertTrue(tx.invoice_ids.is_move_sent)
def test_so_partial_payment_no_invoice(self):
# 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 / 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()
self.assertEqual(self.sale_order.state, 'draft')
self.assertFalse(tx.invoice_ids)
self.assertFalse(self.sale_order.invoice_ids)
def test_already_confirmed_so_payment(self):
# Set automatic invoice
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
# Confirm order before payment
self.sale_order.action_confirm()
# 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()
self.assertTrue(tx.invoice_ids)
self.assertTrue(self.sale_order.invoice_ids)

View file

@ -0,0 +1,312 @@
# -*- 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,84 @@
# -*- coding: utf-8 -*-
from odoo.addons.sale.tests.common import TestSaleCommonBase
class TestSaleFlow(TestSaleCommonBase):
''' Test running at-install to test flows independently to other modules, e.g. 'sale_stock'. '''
@classmethod
def setUpClass(cls):
super().setUpClass()
user = cls.env['res.users'].create({
'name': 'Because I am saleman!',
'login': 'saleman',
'groups_id': [(6, 0, cls.env.user.groups_id.ids), (4, cls.env.ref('account.group_account_user').id)],
})
user.partner_id.email = 'saleman@test.com'
# Shadow the current environment/cursor with the newly created user.
cls.env = cls.env(user=user)
cls.cr = cls.env.cr
cls.company = cls.env['res.company'].create({
'name': 'Test Company',
'currency_id': cls.env.ref('base.USD').id,
})
cls.company_data = cls.setup_sale_configuration_for_company(cls.company)
cls.partner_a = cls.env['res.partner'].create({
'name': 'partner_a',
'company_id': False,
})
cls.analytic_plan = cls.env['account.analytic.plan'].create({
'name': 'Plan',
'company_id': cls.company.id,
})
cls.analytic_account = cls.env['account.analytic.account'].create({
'name': 'Test analytic_account',
'code': 'analytic_account',
'plan_id': cls.analytic_plan.id,
'company_id': cls.company.id,
'partner_id': cls.partner_a.id
})
user.company_ids |= cls.company
user.company_id = cls.company
def test_qty_delivered(self):
''' Test 'qty_delivered' at-install to avoid a change in the behavior when 'sale_stock' is installed. '''
sale_order = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'analytic_account_id': self.analytic_account.id,
'pricelist_id': self.company_data['default_pricelist'].id,
'order_line': [
(0, 0, {
'name': self.company_data['product_order_cost'].name,
'product_id': self.company_data['product_order_cost'].id,
'product_uom_qty': 2,
'qty_delivered': 1,
'product_uom': self.company_data['product_order_cost'].uom_id.id,
'price_unit': self.company_data['product_order_cost'].list_price,
}),
(0, 0, {
'name': self.company_data['product_delivery_cost'].name,
'product_id': self.company_data['product_delivery_cost'].id,
'product_uom_qty': 4,
'qty_delivered': 1,
'product_uom': self.company_data['product_delivery_cost'].uom_id.id,
'price_unit': self.company_data['product_delivery_cost'].list_price,
}),
],
})
sale_order.action_confirm()
self.assertRecordValues(sale_order.order_line, [
{'qty_delivered': 1.0},
{'qty_delivered': 1.0},
])

View file

@ -0,0 +1,18 @@
# -*- 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()

View file

@ -0,0 +1,847 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from freezegun import freeze_time
from odoo import fields
from odoo.fields import Command
from odoo.exceptions import AccessError, UserError
from odoo.tests import tagged, Form
from odoo.tools import float_compare
from odoo.addons.sale.tests.common import SaleCommon
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
@tagged('post_install', '-at_install')
class TestSaleOrder(SaleCommon):
# Those tests do not rely on accounting common on purpose
# If you need the accounting setup, use other classes (TestSaleToInvoice probably)
def test_computes_auto_fill(self):
free_product, dummy_product = self.env['product.product'].create([{
'name': 'Free product',
'list_price': 0.0,
}, {
'name': 'Dummy product',
'list_price': 0.0,
}])
# Test pre-computes of lines with order
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'display_type': 'line_section',
'name': 'Dummy section',
}),
Command.create({
'display_type': 'line_section',
'name': 'Dummy section',
}),
Command.create({
'product_id': free_product.id,
}),
Command.create({
'product_id': dummy_product.id,
})
]
})
# Test pre-computes of lines creation alone
# Ensures the creation works fine even if the computes
# are triggered after the defaults
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
})
self.env['sale.order.line'].create([
{
'display_type': 'line_section',
'name': 'Dummy section',
'order_id': order.id,
}, {
'display_type': 'line_section',
'name': 'Dummy section',
'order_id': order.id,
}, {
'product_id': free_product.id,
'order_id': order.id,
}, {
'product_id': dummy_product.id,
'order_id': order.id,
}
])
def test_sale_order_standard_flow(self):
self.assertEqual(self.sale_order.amount_total, 725.0, 'Sale: total amount is wrong')
self.sale_order.order_line._compute_product_updatable()
self.assertTrue(self.sale_order.order_line[0].product_updatable)
# send quotation
email_act = self.sale_order.action_quotation_send()
email_ctx = email_act.get('context', {})
self.sale_order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
self.assertTrue(self.sale_order.state == 'sent', 'Sale: state after sending is wrong')
self.sale_order.order_line._compute_product_updatable()
self.assertTrue(self.sale_order.order_line[0].product_updatable)
# confirm quotation
self.sale_order.action_confirm()
self.assertTrue(self.sale_order.state == 'sale')
self.assertTrue(self.sale_order.invoice_status == 'to invoice')
def test_sale_order_send_to_self(self):
# when sender(logged in user) is also present in recipients of the mail composer,
# user should receive mail.
sale_order = self.env['sale.order'].with_user(self.sale_user).create({
'partner_id': self.sale_user.partner_id.id,
})
email_ctx = sale_order.action_quotation_send().get('context', {})
# We need to prevent auto mail deletion, and so we copy the template and send the mail with
# added configuration in copied template. It will allow us to check whether mail is being
# sent to to author or not (in case author is present in 'Recipients' of composer).
mail_template = self.env['mail.template'].browse(email_ctx.get('default_template_id')).copy({'auto_delete': False})
# send the mail with same user as customer
sale_order.with_context(**email_ctx).with_user(self.sale_user).message_post_with_template(mail_template.id)
self.assertTrue(sale_order.state == 'sent', 'Sale : state should be changed to sent')
mail_message = sale_order.message_ids[0]
self.assertEqual(mail_message.author_id, sale_order.partner_id, 'Sale: author should be same as customer')
self.assertEqual(mail_message.author_id, mail_message.partner_ids, 'Sale: author should be in composer recipients thanks to "partner_to" field set on template')
self.assertEqual(mail_message.partner_ids, mail_message.sudo().mail_ids.recipient_ids, 'Sale: author should receive mail due to presence in composer recipients')
def test_sale_sequence(self):
self.env['ir.sequence'].search([
('code', '=', 'sale.order'),
]).write({
'use_date_range': True, 'prefix': 'SO/%(range_year)s/',
})
sale_order = self.sale_order.copy({'date_order': '2019-01-01'})
self.assertTrue(sale_order.name.startswith('SO/2019/'))
sale_order = self.sale_order.copy({'date_order': '2020-01-01'})
self.assertTrue(sale_order.name.startswith('SO/2020/'))
# In EU/BXL tz, this is actually already 01/01/2020
sale_order = self.sale_order.with_context(tz='Europe/Brussels').copy({'date_order': '2019-12-31 23:30:00'})
self.assertTrue(sale_order.name.startswith('SO/2020/'))
def test_unlink_cancel(self):
""" Test deleting and cancelling sales orders depending on their state and on the user's rights """
# SO in state 'draft' can be deleted
so_copy = self.sale_order.copy()
with self.assertRaises(AccessError):
so_copy.with_user(self.sale_user).unlink()
self.assertTrue(so_copy.unlink(), 'Sale: deleting a quotation should be possible')
# SO in state 'cancel' can be deleted
so_copy = self.sale_order.copy()
so_copy.action_confirm()
self.assertTrue(so_copy.state == 'sale', 'Sale: SO should be in state "sale"')
so_copy._action_cancel()
self.assertTrue(so_copy.state == 'cancel', 'Sale: SO should be in state "cancel"')
with self.assertRaises(AccessError):
so_copy.with_user(self.sale_user).unlink()
self.assertTrue(so_copy.unlink(), 'Sale: deleting a cancelled SO should be possible')
# SO in state 'sale' or 'done' cannot be deleted
self.sale_order.action_confirm()
self.assertTrue(self.sale_order.state == 'sale', 'Sale: SO should be in state "sale"')
with self.assertRaises(UserError):
self.sale_order.unlink()
self.sale_order.action_done()
self.assertTrue(self.sale_order.state == 'done', 'Sale: SO should be in state "done"')
with self.assertRaises(UserError):
self.sale_order.unlink()
def test_compute_packaging_00(self):
"""Create a SO and use packaging. Check we suggested suitable packaging
according to the product_qty. Also check product_qty or product_packaging
are correctly calculated when one of them changed.
"""
# Required for `product_packaging_qty` to be visible in the view
self.env.user.groups_id += self.env.ref('product.group_stock_packaging')
packaging_single, packaging_dozen = self.env['product.packaging'].create([{
'name': "I'm a packaging",
'product_id': self.product.id,
'qty': 1.0,
}, {
'name': "I'm also a packaging",
'product_id': self.product.id,
'qty': 12.0,
}])
so = self.empty_order
so_form = Form(so)
with so_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 1.0
so_form.save()
self.assertEqual(so.order_line.product_packaging_id, packaging_single)
self.assertEqual(so.order_line.product_packaging_qty, 1.0)
with so_form.order_line.edit(0) as line:
line.product_packaging_qty = 2.0
so_form.save()
self.assertEqual(so.order_line.product_uom_qty, 2.0)
with so_form.order_line.edit(0) as line:
line.product_uom_qty = 24.0
so_form.save()
self.assertEqual(so.order_line.product_packaging_id, packaging_dozen)
self.assertEqual(so.order_line.product_packaging_qty, 2.0)
with so_form.order_line.edit(0) as line:
line.product_packaging_qty = 1.0
so_form.save()
self.assertEqual(so.order_line.product_uom_qty, 12)
packaging_pack_of_10 = self.env['product.packaging'].create({
'name': "PackOf10",
'product_id': self.product.id,
'qty': 10.0,
})
packaging_pack_of_20 = self.env['product.packaging'].create({
'name': "PackOf20",
'product_id': self.product.id,
'qty': 20.0,
})
so2 = self.env['sale.order'].create({
'partner_id': self.partner.id,
})
so2_form = Form(so2)
with so2_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 10
so2_form.save()
self.assertEqual(so2.order_line.product_packaging_id.id, packaging_pack_of_10.id)
self.assertEqual(so2.order_line.product_packaging_qty, 1.0)
with so2_form.order_line.edit(0) as line:
line.product_packaging_qty = 2
so2_form.save()
self.assertEqual(so2.order_line.product_uom_qty, 20)
# we should have 2 pack of 10, as we've set the package_qty manually,
# we shouldn't recompute the packaging_id, since the package_qty is protected,
# therefor cannot be recomputed during the same transaction, which could lead
# to an incorrect line like (qty=20,pack_qty=2,pack_id=PackOf20)
self.assertEqual(so2.order_line.product_packaging_qty, 2)
self.assertEqual(so2.order_line.product_packaging_id.id, packaging_pack_of_10.id)
with so2_form.order_line.edit(0) as line:
line.product_packaging_id = packaging_pack_of_20
so2_form.save()
self.assertEqual(so2.order_line.product_uom_qty, 20)
# we should have 1 pack of 20, as we've set the package type manually
self.assertEqual(so2.order_line.product_packaging_qty, 1)
self.assertEqual(so2.order_line.product_packaging_id.id, packaging_pack_of_20.id)
def test_compute_packaging_01(self):
"""Create a SO and use packaging in a multicompany environment.
Ensure any suggested packaging matches the SO's.
"""
company2 = self.env['res.company'].create([{'name': 'Company 2'}])
generic_single_pack = self.env['product.packaging'].create({
'name': "single pack",
'product_id': self.product.id,
'qty': 1.0,
'company_id': False,
})
company2_pack_of_10 = self.env['product.packaging'].create({
'name': "pack of 10 by Company 2",
'product_id': self.product.id,
'qty': 10.0,
'company_id': company2.id,
})
so1 = self.empty_order
so1_form = Form(so1)
with so1_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 10.0
so1_form.save()
self.assertEqual(so1.order_line.product_packaging_id, generic_single_pack)
self.assertEqual(so1.order_line.product_packaging_qty, 10.0)
so2 = self.env['sale.order'].with_company(company2).create({
'partner_id': self.partner.id,
})
so2_form = Form(so2)
with so2_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 10.0
so2_form.save()
self.assertEqual(so2.order_line.product_packaging_id, company2_pack_of_10)
self.assertEqual(so2.order_line.product_packaging_qty, 1.0)
def _create_sale_order(self):
"""Create dummy sale order (without lines)"""
return self.env['sale.order'].with_context(
default_sale_order_template_id=False
# Do not modify test behavior even if sale_management is installed
).create({
'partner_id': self.partner.id,
})
def test_invoicing_terms(self):
# Enable invoicing terms
self.env['ir.config_parameter'].sudo().set_param('account.use_invoice_terms', True)
# Plain invoice terms
self.env.company.terms_type = 'plain'
self.env.company.invoice_terms = "Coin coin"
sale_order = self._create_sale_order()
self.assertEqual(sale_order.note, "<p>Coin coin</p>")
# Html invoice terms (/terms page)
self.env.company.terms_type = 'html'
sale_order = self._create_sale_order()
self.assertTrue(sale_order.note.startswith("<p>Terms &amp; Conditions: "))
def test_validity_days(self):
self.env['ir.config_parameter'].sudo().set_param('sale.use_quotation_validity_days', True)
self.env.company.quotation_validity_days = 5
with freeze_time("2020-05-02"):
sale_order = self._create_sale_order()
self.assertEqual(sale_order.validity_date, fields.Date.today() + timedelta(days=5))
self.env.company.quotation_validity_days = 0
sale_order = self._create_sale_order()
self.assertFalse(
sale_order.validity_date,
"No validity date must be specified if the company validity duration is 0")
def test_so_names(self):
"""Test custom context key for name_get & name_search.
Note: this key is used in sale_expense & sale_timesheet modules.
"""
SaleOrder = self.env['sale.order'].with_context(sale_show_partner_name=True)
res = SaleOrder.name_search(name=self.sale_order.partner_id.name)
self.assertEqual(res[0][0], self.sale_order.id)
self.assertNotIn(self.sale_order.partner_id.name, self.sale_order.display_name)
self.assertIn(
self.sale_order.partner_id.name,
self.sale_order.with_context(sale_show_partner_name=True).name_get()[0][1])
def test_state_changes(self):
"""Test some untested state changes methods & logic."""
self.sale_order.action_quotation_sent()
self.assertEqual(self.sale_order.state, 'sent')
self.assertIn(self.sale_order.partner_id, self.sale_order.message_follower_ids.partner_id)
self.env.user.groups_id += self.env.ref('sale.group_auto_done_setting')
self.sale_order.action_confirm()
self.assertEqual(self.sale_order.state, 'done', "The order wasn't automatically locked at confirmation.")
with self.assertRaises(UserError):
self.sale_order.action_confirm()
self.sale_order.action_unlock()
self.assertEqual(self.sale_order.state, 'sale')
def test_sol_name_search(self):
# Shouldn't raise
self.env['sale.order']._search([('order_line', 'ilike', 'product')])
name_search_data = self.env['sale.order.line'].name_search(name=self.sale_order.name)
sol_ids_found = dict(name_search_data).keys()
self.assertEqual(list(sol_ids_found), self.sale_order.order_line.ids)
def test_zero_quantity(self):
"""
If the quantity set is 0 it should remain to 0
Test that changing the uom do not change the quantity
"""
order_line = self.sale_order.order_line[0]
order_line.product_uom_qty = 0.0
order_line.product_uom = self.uom_dozen
self.assertEqual(order_line.product_uom_qty, 0.0)
def test_discount_rounding(self):
"""
Check the discount is properly rounded and the price subtotal
computed with this rounded discount
"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 192,
'discount': 74.246,
})]
})
self.assertEqual(sale_order.order_line.price_subtotal, 49.44, "Subtotal should be equal to 192 * (1 - 0.7425)")
self.assertEqual(sale_order.order_line.discount, 74.25)
def test_tax_amount_rounding(self):
""" Check order amounts are rounded according to settings """
tax_a = self.env['account.tax'].create({
'name': 'Test tax',
'type_tax_use': 'sale',
'price_include': False,
'amount_type': 'percent',
'amount': 15.0,
})
# Test Round per Line (default)
self.env.company.tax_calculation_rounding_method = 'round_per_line'
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 6.7,
'discount': 0,
'tax_id': tax_a.ids,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 6.7,
'discount': 0,
'tax_id': tax_a.ids,
}),
],
})
self.assertEqual(sale_order.amount_total, 15.42, "")
# Test Round Globally
self.env.company.tax_calculation_rounding_method = 'round_globally'
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 6.7,
'discount': 0,
'tax_id': tax_a.ids,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 6.7,
'discount': 0,
'tax_id': tax_a.ids,
}),
],
})
self.assertEqual(sale_order.amount_total, 15.41, "")
def test_order_auto_lock_with_public_user(self):
public_user = self.env.ref('base.public_user')
self.sale_order.create_uid.groups_id += self.env.ref('sale.group_auto_done_setting')
self.sale_order.with_user(public_user.id).sudo().action_confirm()
self.assertFalse(public_user.has_group('sale.group_auto_done_setting'))
self.assertEqual(self.sale_order.state, 'done')
@tagged('post_install', '-at_install')
class TestSaleOrderInvoicing(AccountTestInvoicingCommon, SaleCommon):
def test_invoice_state_when_ordered_quantity_is_negative(self):
"""When you invoice a SO line with a product that is invoiced on ordered quantities and has negative ordered quantity,
this test ensures that the invoicing status of the SO line is 'invoiced' (and not 'upselling')."""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': -1,
})]
})
sale_order.action_confirm()
sale_order._create_invoices(final=True)
self.assertTrue(sale_order.invoice_status == 'invoiced', 'Sale: The invoicing status of the SO should be "invoiced"')
@tagged('post_install', '-at_install')
class TestSalesTeam(SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
# set up users
cls.sale_team_2 = cls.env['crm.team'].create({
'name': 'Test Sales Team (2)',
})
cls.user_in_team = cls.env['res.users'].create({
'email': 'team0user@example.com',
'login': 'team0user',
'name': 'User in Team 0',
})
cls.sale_team.write({'member_ids': [4, cls.user_in_team.id]})
cls.user_not_in_team = cls.env['res.users'].create({
'email': 'noteamuser@example.com',
'login': 'noteamuser',
'name': 'User Not In Team',
})
def test_assign_sales_team_from_partner_user(self):
"""Use the team from the customer's sales person, if it is set"""
partner = self.env['res.partner'].create({
'name': 'Customer of User In Team',
'user_id': self.user_in_team.id,
'team_id': self.sale_team_2.id,
})
sale_order = self.env['sale.order'].create({
'partner_id': partner.id,
})
self.assertEqual(sale_order.team_id.id, self.sale_team.id, 'Should assign to team of sales person')
def test_assign_sales_team_from_partner_team(self):
"""If no team set on the customer's sales person, fall back to the customer's team"""
partner = self.env['res.partner'].create({
'name': 'Customer of User Not In Team',
'user_id': self.user_not_in_team.id,
'team_id': self.sale_team_2.id,
})
sale_order = self.env['sale.order'].create({
'partner_id': partner.id,
})
self.assertEqual(sale_order.team_id.id, self.sale_team_2.id, 'Should assign to team of partner')
def test_assign_sales_team_when_changing_user(self):
"""When we assign a sales person, change the team on the sales order to their team"""
sale_order = self.env['sale.order'].create({
'user_id': self.user_not_in_team.id,
'partner_id': self.partner.id,
'team_id': self.sale_team_2.id
})
sale_order.user_id = self.user_in_team
self.assertEqual(sale_order.team_id.id, self.sale_team.id, 'Should assign to team of sales person')
def test_keep_sales_team_when_changing_user_with_no_team(self):
"""When we assign a sales person that has no team, do not reset the team to default"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'team_id': self.sale_team_2.id
})
sale_order.user_id = self.user_not_in_team
self.assertEqual(sale_order.team_id.id, self.sale_team_2.id, 'Should not reset the team to default')
def test_sale_order_analytic_distribution_change(self):
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
analytic_plan = self.env['account.analytic.plan'].create({'name': 'Plan Test', 'company_id': False})
analytic_account_super = self.env['account.analytic.account'].create({'name': 'Super Account', 'plan_id': analytic_plan.id})
analytic_account_great = self.env['account.analytic.account'].create({'name': 'Great Account', 'plan_id': analytic_plan.id})
super_product = self.env['product.product'].create({'name': 'Super Product'})
great_product = self.env['product.product'].create({'name': 'Great Product'})
product_no_account = self.env['product.product'].create({'name': 'Product No Account'})
self.env['account.analytic.distribution.model'].create([
{
'analytic_distribution': {analytic_account_super.id: 100},
'product_id': super_product.id,
},
{
'analytic_distribution': {analytic_account_great.id: 100},
'product_id': great_product.id,
},
])
partner = self.env['res.partner'].create({'name': 'Test Partner'})
sale_order = self.env['sale.order'].create({
'partner_id': partner.id,
})
sol = self.env['sale.order.line'].create({
'name': super_product.name,
'product_id': super_product.id,
'order_id': sale_order.id,
})
self.assertEqual(sol.analytic_distribution, {str(analytic_account_super.id): 100}, "The analytic distribution should be set to Super Account")
sol.write({'product_id': great_product.id})
self.assertEqual(sol.analytic_distribution, {str(analytic_account_great.id): 100}, "The analytic distribution should be set to Great Account")
so_no_analytic_account = self.env['sale.order'].create({
'partner_id': partner.id,
})
sol_no_analytic_account = self.env['sale.order.line'].create({
'name': super_product.name,
'product_id': super_product.id,
'order_id': so_no_analytic_account.id,
'analytic_distribution': False,
})
so_no_analytic_account.action_confirm()
self.assertFalse(sol_no_analytic_account.analytic_distribution, "The compute should not overwrite what the user has set.")
sale_order.action_confirm()
sol_on_confirmed_order = self.env['sale.order.line'].create({
'name': super_product.name,
'product_id': super_product.id,
'order_id': sale_order.id,
})
self.assertEqual(
sol_on_confirmed_order.analytic_distribution,
{str(analytic_account_super.id): 100},
"The analytic distribution should be set to Super Account, even for confirmed orders"
)
def test_cannot_assign_tax_of_mismatch_company(self):
""" Test that sol cannot have assigned tax belonging to a different company from that of the sale order. """
company_a = self.env['res.company'].create({'name': 'A'})
company_b = self.env['res.company'].create({'name': 'B'})
tax_a = self.env['account.tax'].create({
'name': 'A',
'amount': 10,
'company_id': company_a.id,
})
tax_b = self.env['account.tax'].create({
'name': 'B',
'amount': 10,
'company_id': company_b.id,
})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'company_id': company_a.id
})
product = self.env['product.product'].create({'name': 'Product'})
# In sudo to simulate an user that have access to both companies.
sol = self.env['sale.order.line'].sudo().create({
'name': product.name,
'product_id': product.id,
'order_id': sale_order.id,
'tax_id': tax_a,
})
with self.assertRaises(UserError):
sol.tax_id = tax_b
def test_sales_team_defined_on_partner_user_no_team(self):
""" Test that sale order picks up a team from res.partner on change if user has no team specified """
crm_team0 = self.env['crm.team'].create({
'name':"Test Team A"
})
crm_team1 = self.env['crm.team'].create({
'name':"Test Team B"
})
partner_a = self.env['res.partner'].create({
'name': 'Partner A',
'team_id': crm_team0.id,
})
partner_b = self.env['res.partner'].create({
'name': 'Partner B',
'team_id': crm_team1.id,
})
sale_order = self.env['sale.order'].with_user(self.user_not_in_team).create({
'partner_id': partner_a.id,
})
self.assertEqual(sale_order.team_id, crm_team0, "Sales team should change to partner's")
sale_order.with_user(self.user_not_in_team).write({'partner_id': partner_b.id})
self.assertEqual(sale_order.team_id, crm_team1, "Sales team should change to partner's")
def test_qty_delivered_on_creation(self):
"""Checks that the qty delivered of sol is automatically set to 0.0 when an so is created"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.product.id,
})],
})
# As we want to determine if the value is set in the DB we need to perform a search
self.assertFalse(self.env['sale.order.line'].search(['&', ('order_id', '=', sale_order.id), ('qty_delivered', '=', False)]))
self.assertEqual(self.env['sale.order.line'].search(['&', ('order_id', '=', sale_order.id), ('qty_delivered', '=', 0.0)]), sale_order.order_line)
def test_action_recompute_taxes(self):
'''
This test verifies the taxes recomputation action that can be triggered
after updating the fiscal position on a sale order document.
'''
special_tax = self.env['account.tax'].create({
'name': "special_tax_10",
'amount_type': 'percent',
'amount': 25.0,
'include_base_amount': True,
'price_include': True,
})
mapped_tax_a = self.env['account.tax'].create({
'name': "tax_a",
'amount_type': 'percent',
'amount': 12.5,
'include_base_amount': True,
'price_include': True,
})
mapped_tax_b = self.env['account.tax'].create({
'name': "tax_b",
'amount_type': 'percent',
'amount': 5.0,
'include_base_amount': True,
'price_include': True,
})
sales_tax = self.env['account.tax'].create({
'name': "VAT 20%",
'amount_type': 'percent',
'amount': 20.0,
'price_include': True,
})
mapping_a = self.env['account.fiscal.position'].create({
'name': 'Special Tax Reduction',
'tax_ids': [Command.create({'tax_src_id': special_tax.id, 'tax_dest_id': mapped_tax_a.id})],
})
mapping_b = self.env['account.fiscal.position'].create({
'name': 'Special Tax Reduction',
'tax_ids': [Command.create({'tax_src_id': special_tax.id, 'tax_dest_id': mapped_tax_b.id})],
})
# taxes and standard price need to be set on the product, as they will be
# recomputed when changing the fiscal position.
self.consumable_product.write({
'lst_price': 300,
'taxes_id': [Command.set((special_tax + sales_tax).ids)],
})
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
Command.create({
'product_id': self.consumable_product.id,
'product_uom_qty': 1.0,
}),
],
})
self.assertEqual(order.amount_total, 300)
self.assertEqual(order.amount_tax, 100)
order.fiscal_position_id = mapping_a
order._recompute_prices()
order.action_update_taxes()
self.assertEqual(order.amount_total, 270)
self.assertEqual(order.amount_tax, 70)
order.fiscal_position_id = mapping_b
order._recompute_prices()
order.action_update_taxes()
self.assertEqual(order.amount_total, 252)
self.assertEqual(order.amount_tax, 52)
def test_recompute_taxes_rounded_globally_multi_company_currency(self):
'''
Check that taxes computation are made in the currency of the company
configured on the SO lines.
'''
# create a currency with no decimal
currency_b = self.env['res.currency'].create({
'name': 'B',
'symbol': 'B',
'rounding': 1.000000,
})
# create a company with USD as currency (rounding == 0.01)
company_a = self.env['res.company'].create({
'name': 'Company A',
'country_id': self.env.ref('base.us').id,
})
# create a company with currency_b as currency
company_b = self.env['res.company'].create({
'name': 'Company B',
'tax_calculation_rounding_method': 'round_globally',
'currency_id': currency_b.id,
})
# set company_b as default company of current user
self.env.user.company_id = company_b
tax_a = self.env['account.tax'].create({
'name': 'Tax A',
'amount': 10,
'company_id': company_a.id,
'country_id': self.env.ref('base.us').id,
})
# create a SO from company_a
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'company_id': company_a.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 123.4,
'tax_id': tax_a.ids,
}),
],
})
# edit the price unit
so.write({
'order_line': [
Command.update(so.order_line[0].id, {'price_unit': 123.5}),
],
})
# call "flush_all" as it is done in "call_kw" method to test
# the tax values that have been recomputed after it
self.env.flush_all()
self.assertEqual(so.amount_tax, 12.35)
self.assertEqual(so.amount_total, 135.85)
# set "Rounding Method" of company A to "Round Globally"
company_a.tax_calculation_rounding_method = 'round_globally'
# edit the price unit
so.write({
'order_line': [
Command.update(so.order_line[0].id, {'price_unit': 123.6}),
],
})
self.env.flush_all()
self.assertEqual(so.amount_tax, 12.36)
self.assertEqual(so.amount_total, 135.96)
def test_recompute_taxes_rounded_globally_currency_precision(self):
'''
Check that taxes computation are made in the currency of the company
configured on the SO lines.
'''
# create a currency with no decimal
currency_b = self.env['res.currency'].create({
'name': 'B',
'symbol': 'B',
'rounding': 1.000000,
})
# create a company with currency_b as currency
company_b = self.env['res.company'].create({
'name': 'Company B',
'tax_calculation_rounding_method': 'round_globally',
'currency_id': currency_b.id,
})
# set company_b as default company of current user
self.env.user.company_id = company_b
pricelist_b = self.env['product.pricelist'].with_company(company_b).create({
'name': 'pricelist b',
'currency_id': self.env.ref('base.USD').id,
})
tax = self.env['account.tax'].create({
'name': 'Tax A',
'amount': 19,
'company_id': company_b.id,
'country_id': self.env.ref('base.us').id,
})
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'company_id': company_b.id,
'pricelist_id': pricelist_b.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 15.31,
'tax_id': tax.ids,
}),
],
})
self.assertEqual(so.amount_tax, 2.91)
self.assertEqual(so.amount_total, 18.22)
self.assertEqual(so.order_line.price_tax, 2.91)
self.assertEqual(so.order_line.price_total, 18.22)

View file

@ -0,0 +1,46 @@
# -*- 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,985 @@
# -*- 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 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.addons.sale.tests.common import SaleCommon
@tagged('post_install', '-at_install')
class TestSalePrices(SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
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')
def _create_discount_pricelist_rule(self, **additional_values):
return self.env['product.pricelist.item'].create({
'pricelist_id': self.pricelist.id,
'compute_price': 'percentage',
'percent_price': self.discount,
**additional_values,
})
def test_pricelist_minimal_qty(self):
""" Verify the quantity and uom are correctly provided to the pricelist API"""
pricelist_rule = self._create_discount_pricelist_rule(
min_quantity=4.0,
)
product_price = self.product.lst_price
product_dozen_price = product_price * 12
discount = 1 - self.discount/100
self.empty_order.order_line = [
Command.create({
'product_id': self.product.id,
'product_uom_qty': 3.0,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 4.0,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 5.0,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 1.0,
'product_uom': self.uom_dozen.id,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 0.4,
'product_uom': self.uom_dozen.id,
}),
Command.create({
'product_id': self.product.id,
'product_uom_qty': 0.3,
'product_uom': 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.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))
def test_pricelist_dates(self):
""" Verify the order date is correctly provided to the pricelist API"""
today = fields.Datetime.today()
tomorrow = today + timedelta(days=1)
pricelist_rule = self._create_discount_pricelist_rule(
date_start=today - timedelta(hours=1),
date_end=today + timedelta(hours=23),
)
with freeze_time(today):
# Create an order today, add line today, rule active today works
self.empty_order.date_order = today
order_line = self.env['sale.order.line'].create({
'order_id': self.empty_order.id,
'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)
# Create an order tomorrow, add line today, rule active today doesn't work
self.empty_order.date_order = tomorrow
order_line = self.env['sale.order.line'].create({
'order_id': self.empty_order.id,
'product_id': self.product.id,
})
self.assertFalse(order_line.pricelist_item_id)
self.assertEqual(order_line.price_unit, self.product.lst_price)
self.assertEqual(order_line.discount, 0.0)
with freeze_time(tomorrow):
# Create an order tomorrow, add line tomorrow, rule active today doesn't work
self.empty_order.date_order = tomorrow
order_line = self.env['sale.order.line'].create({
'order_id': self.empty_order.id,
'product_id': self.product.id,
})
self.assertFalse(order_line.pricelist_item_id)
self.assertEqual(order_line.price_unit, self.product.lst_price)
self.assertEqual(order_line.discount, 0.0)
# Create an order today, add line tomorrow, rule active today works
self.empty_order.date_order = today
order_line = self.env['sale.order.line'].create({
'order_id': self.empty_order.id,
'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.assertEqual(
self.empty_order.amount_untaxed,
self.product.lst_price * 3.8) # Discount of 10% on 2 of the 4 sol
def test_pricelist_product_context(self):
""" Verify that the product attributes extra prices are correctly considered """
no_variant_attribute = self.env['product.attribute'].create({
'name': 'No Variant Test Attribute',
'create_variant': 'no_variant',
'value_ids': [
Command.create({'name': 'A'}),
Command.create({'name': 'B'}),
Command.create({'name': 'C'}),
],
})
product_template = self.env['product.template'].create({
'name': 'Test Template with no_variant attributes',
'categ_id': self.product_category.id,
'attribute_line_ids': [
Command.create({
'attribute_id': no_variant_attribute.id,
'value_ids': [Command.set(no_variant_attribute.value_ids.ids)],
}),
],
'list_price': 75.0,
'taxes_id': False,
})
# Specify an extra_price on a variant
ptavs = product_template.attribute_line_ids.product_template_value_ids
ptavs[0].price_extra = 5.0
ptavs[2].price_extra = 25.0
self.empty_order.order_line = [
Command.create({
'product_id': product_template.product_variant_id.id,
'product_no_variant_attribute_value_ids': [Command.link(ptav.id)]
})
for ptav in ptavs
]
order_lines = self.empty_order.order_line
self.assertEqual(order_lines[0].price_unit, 80.0)
self.assertEqual(order_lines[1].price_unit, 75.0)
self.assertEqual(order_lines[2].price_unit, 100.0)
def test_no_pricelist_rules(self):
"""Check currencies and uom conversions when no pricelist rule is available"""
# UoM Conversion
# Selling dozens => price_unit = 12*price by unit
self.empty_order.order_line = [
Command.create({
'product_id': self.product.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 2.0,
}),
]
self.assertEqual(self.empty_order.order_line.price_unit, 240.0)
other_currency = self._enable_currency('EUR')
pricelist_in_other_curr = self.env['product.pricelist'].create({
'name': 'Test Pricelist (EUR)',
'currency_id': other_currency.id,
})
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,
'company_id': self.env.company.id,
})
order_in_other_currency = self.env['sale.order'].create({
'partner_id': self.partner.id,
'pricelist_id': pricelist_in_other_curr.id,
'order_line': [
Command.create({
'product_id': self.product.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 2.0,
}),
]
})
self.assertEqual(order_in_other_currency.amount_total, 480.0)
def test_negative_discounts(self):
"""aka surcharges"""
self.discount = -10
rule = self._create_discount_pricelist_rule()
order_line = self.env['sale.order.line'].create({
'order_id': self.empty_order.id,
'product_id': self.product.id,
})
self.assertEqual(order_line.price_unit, 22.0)
self.assertEqual(order_line.pricelist_item_id, rule)
# 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,
})
self.assertEqual(order_line.price_unit, 22.0)
self.assertEqual(order_line.pricelist_item_id, rule)
def test_pricelist_based_on_another(self):
""" Test price and discount are correctly applied with a pricelist based on an other one"""
self.product.lst_price = 100
base_pricelist = self.env['product.pricelist'].create({
'name': 'First pricelist',
'discount_policy': 'without_discount',
'item_ids': [Command.create({
'compute_price': 'percentage',
'base': 'list_price',
'percent_price': 10,
'applied_on': '3_global',
'name': 'First discount',
})],
})
self.pricelist.write({
'discount_policy': 'without_discount',
'item_ids': [Command.create({
'compute_price': 'formula',
'base': 'pricelist',
'base_pricelist_id': base_pricelist.id,
'price_discount': 10,
'applied_on': '3_global',
'name': 'Second discount',
})],
})
self.empty_order.write({
'date_order': '2018-07-11',
})
order_line = self.env['sale.order.line'].create({
'order_id': self.empty_order.id,
'product_id': self.product.id,
})
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")
def test_pricelist_with_another_currency(self):
""" Test prices are correctly applied with a pricelist with another currency"""
self.product.lst_price = 100
currency_eur = self._enable_currency('EUR')
self.env['res.currency.rate'].create({
'name': '2018-07-11',
'rate': 2.0,
'currency_id': currency_eur.id,
'company_id': self.env.company.id,
})
with mute_logger('odoo.models.unlink'):
self.env['res.currency.rate'].search(
[('currency_id', '=', self.env.company.currency_id.id)]
).unlink()
new_uom = self.env['uom.uom'].create({
'name': '10 units',
'factor_inv': 10,
'uom_type': 'bigger',
'rounding': 1.0,
'category_id': self.uom_unit.category_id.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',
'percent_price': 10,
'applied_on': '3_global',
'name': 'First discount'
})],
})
self.empty_order.write({
'date_order': '2018-07-12',
'pricelist_id': pricelist_eur.id,
})
order_line = self.env['sale.order.line'].create({
'order_id': self.empty_order.id,
'product_id': self.product.id,
})
# 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")
def test_multi_currency_discount(self):
"""Verify the currency used for pricelist price & discount computation."""
product_1 = self.consumable_product
product_2 = self.service_product
# Make sure the company is in USD
main_company = self.env.ref('base.main_company')
main_curr = main_company.currency_id
current_curr = self.env.company.currency_id # USD
other_curr = self._enable_currency('EUR')
# main_company.currency_id = other_curr # product.currency_id when no company_id set
other_company = self.env['res.company'].create({
'name': 'Test',
'currency_id': other_curr.id
})
user_in_other_company = self.env['res.users'].create({
'company_id': other_company.id,
'company_ids': [Command.set([other_company.id])],
'name': 'E.T',
'login': 'hohoho',
})
with mute_logger('odoo.models.unlink'):
self.env['res.currency.rate'].search([]).unlink()
self.env['res.currency.rate'].create({
'name': '2010-01-01',
'rate': 2.0,
'currency_id': main_curr.id,
'company_id': False,
})
product_1.company_id = False
product_2.company_id = False
self.assertEqual(product_1.currency_id, main_curr)
self.assertEqual(product_2.currency_id, main_curr)
self.assertEqual(product_1.cost_currency_id, current_curr)
self.assertEqual(product_2.cost_currency_id, current_curr)
product_1_ctxt = product_1.with_user(user_in_other_company)
product_2_ctxt = product_2.with_user(user_in_other_company)
self.assertEqual(product_1_ctxt.currency_id, main_curr)
self.assertEqual(product_2_ctxt.currency_id, main_curr)
self.assertEqual(product_1_ctxt.cost_currency_id, other_curr)
self.assertEqual(product_2_ctxt.cost_currency_id, other_curr)
product_1.lst_price = 100.0
product_2_ctxt.standard_price = 10.0 # cost is company_dependent
pricelist = self.env['product.pricelist'].create({
'name': 'Test multi-currency',
'discount_policy': 'without_discount',
'currency_id': other_curr.id,
'item_ids': [
Command.create({
'base': 'list_price',
'product_id': product_1.id,
'compute_price': 'percentage',
'percent_price': 20,
}),
Command.create({
'base': 'standard_price',
'product_id': product_2.id,
'compute_price': 'percentage',
'percent_price': 10,
})
]
})
# Create a SO in the other company
##################################
# product_currency = main_company.currency_id when no company_id on the product
# CASE 1:
# company currency = so currency
# product_1.currency != so currency
# product_2.cost_currency_id = so currency
sales_order = product_1_ctxt.with_context(mail_notrack=True, mail_create_nolog=True).env['sale.order'].create({
'partner_id': self.env.user.partner_id.id,
'pricelist_id': pricelist.id,
'order_line': [
Command.create({
'product_id': product_1.id,
'product_uom_qty': 1.0
}),
Command.create({
'product_id': product_2.id,
'product_uom_qty': 1.0
})
]
})
so_line_1 = sales_order.order_line[0]
so_line_2 = sales_order.order_line[1]
self.assertEqual(so_line_1.discount, 20)
self.assertEqual(so_line_1.price_unit, 50.0)
self.assertEqual(so_line_2.discount, 10)
self.assertEqual(so_line_2.price_unit, 10)
# CASE 2
# company currency != so currency
# product_1.currency == so currency
# product_2.cost_currency_id != so currency
pricelist.currency_id = main_curr
sales_order = product_1_ctxt.with_context(mail_notrack=True, mail_create_nolog=True).env['sale.order'].create({
'partner_id': self.env.user.partner_id.id,
'pricelist_id': pricelist.id,
'order_line': [
# Verify discount is considered in create hack
Command.create({
'product_id': product_1.id,
'product_uom_qty': 1.0
}),
Command.create({
'product_id': product_2.id,
'product_uom_qty': 1.0
})
]
})
so_line_1 = sales_order.order_line[0]
so_line_2 = sales_order.order_line[1]
self.assertEqual(so_line_1.discount, 20)
self.assertEqual(so_line_1.price_unit, 100.0)
self.assertEqual(so_line_2.discount, 10)
self.assertEqual(so_line_2.price_unit, 20)
def test_update_prices(self):
"""Test prices recomputation on SO's.
`_recompute_prices` is shown as a button to update
prices when the pricelist was changed.
"""
sale_order = self.sale_order
so_amount = sale_order.amount_total
sale_order._recompute_prices()
self.assertEqual(
sale_order.amount_total, so_amount,
"Updating the prices of an unmodified SO shouldn't modify the amounts")
pricelist = sale_order.pricelist_id
pricelist.item_ids = [
fields.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"
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)
# 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)
fiscal_pos = self.env['account.fiscal.position'].create({
'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
})]
})
# setting up partner:
self.partner.country_id = country_belgium
self.product.write({
'lst_price': 115,
'taxes_id': [Command.set(tax_a.ids)]
})
self.pricelist.write({
'discount_policy': 'without_discount',
'item_ids': [Command.create({
'applied_on': '3_global',
'compute_price': 'percentage',
'percent_price': 54,
})]
})
# creating SO
self.empty_order.write({
'fiscal_position_id': fiscal_pos.id,
'order_line': [Command.create({
'product_id': self.product.id,
})],
})
# Update Prices
self.empty_order._recompute_prices()
# Check that the discount displayed is the correct one
self.assertEqual(
self.empty_order.order_line.discount, 54,
"Wrong discount computed for specified product & pricelist"
)
# Additional to check for overall consistency
self.assertEqual(
self.empty_order.order_line.price_unit, 100,
"Wrong unit price computed for specified product & pricelist"
)
self.assertEqual(
self.empty_order.order_line.price_subtotal, 46,
"Wrong subtotal price computed for specified product & pricelist"
)
self.assertEqual(
self.empty_order.order_line.tax_id.id, tax_b.id,
"Wrong tax applied for specified product & pricelist"
)
def test_fiscalposition_application(self):
"""Test application of a fiscal position mapping
price included to price included tax
"""
# If test is run without demo data
# pricelists are not automatically enabled
self._enable_pricelists()
pricelist = self.pricelist
partner = self.partner
(
tax_fixed_incl,
tax_fixed_excl,
tax_include_src,
tax_include_dst,
tax_exclude_src,
tax_exclude_dst,
) = self.env['account.tax'].create([{
'name': "fixed include",
'amount': '10.00',
'amount_type': 'fixed',
'price_include': True,
}, {
'name': "fixed exclude",
'amount': '10.00',
'amount_type': 'fixed',
'price_include': False,
}, {
'name': "Include 21%",
'amount': 21.00,
'amount_type': 'percent',
'price_include': True,
}, {
'name': "Include 6%",
'amount': 6.00,
'amount_type': 'percent',
'price_include': True,
}, {
'name': "Exclude 15%",
'amount': 15.00,
'amount_type': 'percent',
'price_include': False,
}, {
'name': "Exclude 21%",
'amount': 21.00,
'amount_type': 'percent',
'price_include': False,
}])
(
product_tmpl_a,
product_tmpl_b,
product_tmpl_c,
product_tmpl_d,
) = self.env['product.template'].create([{
'name': "Voiture",
'list_price': 121,
'taxes_id': [Command.set([tax_include_src.id])]
}, {
'name': "Voiture",
'list_price': 100,
'taxes_id': [Command.set([tax_exclude_src.id])]
}, {
'name': "Voiture",
'list_price': 100,
'taxes_id': [Command.set([tax_fixed_incl.id, tax_exclude_src.id])]
}, {
'name': "Voiture",
'list_price': 100,
'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
SaleOrder = self.env['sale.order']
# Test Mapping included to included
order_form = Form(SaleOrder)
order_form.partner_id = partner
order_form.pricelist_id = pricelist
order_form.fiscal_position_id = fpos_incl_incl
with order_form.order_line.new() as line:
line.name = product_tmpl_a.product_variant_id.name
line.product_id = product_tmpl_a.product_variant_id
line.product_uom_qty = 1.0
sale_order = order_form.save()
self.assertRecordValues(sale_order.order_line, [{'price_unit': 106, 'price_subtotal': 100}])
# Test Mapping excluded to included
order_form = Form(SaleOrder)
order_form.partner_id = partner
order_form.pricelist_id = pricelist
order_form.fiscal_position_id = fpos_excl_incl
with order_form.order_line.new() as line:
line.name = product_tmpl_b.product_variant_id.name
line.product_id = product_tmpl_b.product_variant_id
line.product_uom_qty = 1.0
sale_order = order_form.save()
self.assertRecordValues(sale_order.order_line, [{'price_unit': 100, 'price_subtotal': 94.34}])
# Test Mapping included to excluded
order_form = Form(SaleOrder)
order_form.partner_id = partner
order_form.pricelist_id = pricelist
order_form.fiscal_position_id = fpos_incl_excl
with order_form.order_line.new() as line:
line.name = product_tmpl_a.product_variant_id.name
line.product_id = product_tmpl_a.product_variant_id
line.product_uom_qty = 1.0
sale_order = order_form.save()
self.assertRecordValues(sale_order.order_line, [{'price_unit': 100, 'price_subtotal': 100}])
# Test Mapping excluded to excluded
order_form = Form(SaleOrder)
order_form.partner_id = partner
order_form.pricelist_id = pricelist
order_form.fiscal_position_id = fpos_excl_excl
with order_form.order_line.new() as line:
line.name = product_tmpl_b.product_variant_id.name
line.product_id = product_tmpl_b.product_variant_id
line.product_uom_qty = 1.0
sale_order = order_form.save()
self.assertRecordValues(sale_order.order_line, [{'price_unit': 100, 'price_subtotal': 100}])
# Test Mapping (included,excluded) to (included, included)
order_form = Form(SaleOrder)
order_form.partner_id = partner
order_form.pricelist_id = pricelist
order_form.fiscal_position_id = fpos_excl_incl
with order_form.order_line.new() as line:
line.name = product_tmpl_c.product_variant_id.name
line.product_id = product_tmpl_c.product_variant_id
line.product_uom_qty = 1.0
sale_order = order_form.save()
self.assertRecordValues(sale_order.order_line, [{'price_unit': 100, 'price_subtotal': 84.91}])
# Test Mapping (excluded,included) to (excluded, excluded)
order_form = Form(SaleOrder)
order_form.partner_id = partner
order_form.pricelist_id = pricelist
order_form.fiscal_position_id = fpos_incl_excl
with order_form.order_line.new() as line:
line.name = product_tmpl_d.product_variant_id.name
line.product_id = product_tmpl_d.product_variant_id
line.product_uom_qty = 1.0
sale_order = order_form.save()
self.assertRecordValues(sale_order.order_line, [{'price_unit': 100, 'price_subtotal': 100}])
def test_so_tax_mapping(self):
order = self.empty_order
tax_include, tax_exclude = self.env['account.tax'].create([{
'name': 'Include Tax',
'amount': '21.00',
'price_include': True,
'type_tax_use': 'sale',
}, {
'name': 'Exclude Tax',
'amount': '0.00',
'type_tax_use': 'sale',
}])
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({
'product_id': self.product.id,
})]
})
# Check the unit price of SO line
self.assertEqual(
100, order.order_line[0].price_unit,
"The included tax must be subtracted to the 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([{
'name': 'BEBAT 0.05',
'type_tax_use': 'sale',
'amount_type': 'fixed',
'amount': 0.05,
'price_include': True,
'include_base_amount': True,
}, {
'name': 'Recupel 0.25',
'type_tax_use': 'sale',
'amount_type': 'fixed',
'amount': 0.25,
'price_include': True,
'include_base_amount': True,
}])
order = self.empty_order
order.order_line = [Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 0.0,
'tax_id': [
Command.set(taxes.ids),
],
})]
self.assertRecordValues(order.order_line, [{
'price_tax': 0.3,
'price_subtotal': -0.3,
'price_total': 0.0,
}])
self.assertRecordValues(order, [{
'amount_untaxed': -0.30,
'amount_tax': 0.30,
'amount_total': 0.0,
}])
def test_sale_with_taxes(self):
""" Test SO with taxes applied on its lines and check subtotal applied on its lines and total applied on the SO """
tax_include, tax_exclude = self.env['account.tax'].create([{
'name': 'Tax with price include',
'amount': 10,
'price_include': True
}, {
'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)]})
for line in self.sale_order.order_line:
if line.tax_id.price_include:
price = line.price_unit * line.product_uom_qty - line.price_tax
else:
price = line.price_unit * line.product_uom_qty
self.assertEqual(float_compare(line.price_subtotal, price, precision_digits=2), 0)
self.assertAlmostEqual(
self.sale_order.amount_total,
self.sale_order.amount_untaxed + self.sale_order.amount_tax,
places=2)
def test_discount_and_untaxed_subtotal(self):
"""When adding a discount on a SO line, this test ensures that the untaxed amount to invoice is
equal to the untaxed subtotal"""
self.product.invoice_policy = 'delivery'
order = self.empty_order
order.order_line = [Command.create({
'product_id': self.product.id,
'product_uom_qty': 38,
'price_unit': 541.26,
'discount': 2.00,
})]
order.action_confirm()
line = order.order_line
self.assertEqual(line.untaxed_amount_to_invoice, 0)
line.qty_delivered = 38
# (541.26 - 0.02 * 541.26) * 38 = 20156.5224 ~= 20156.52
self.assertEqual(line.price_subtotal, 20156.52)
self.assertEqual(line.untaxed_amount_to_invoice, line.price_subtotal)
# Same with an included-in-price tax
order = order.copy()
line = order.order_line
line.tax_id = [Command.create({
'name': 'Super Tax',
'amount_type': 'percent',
'amount': 15.0,
'price_include': True,
})]
order.action_confirm()
self.assertEqual(line.untaxed_amount_to_invoice, 0)
line.qty_delivered = 38
# (541,26 / 1,15) * ,98 * 38 = 17527,410782609 ~= 17527.41
self.assertEqual(line.price_subtotal, 17527.41)
self.assertEqual(line.untaxed_amount_to_invoice, line.price_subtotal)
def test_discount_and_amount_undiscounted(self):
"""When adding a discount on a SO line, this test ensures that amount undiscounted is
consistent with the used tax"""
order = self.empty_order
order.order_line = [Command.create({
'product_id': self.product.id,
'product_uom_qty': 1,
'price_unit': 100.0,
'discount': 1.00,
})]
order.action_confirm()
order_line = order.order_line
# test discount and qty 1
self.assertEqual(order.amount_undiscounted, 100.0)
self.assertEqual(order_line.price_subtotal, 99.0)
# more quantity 1 -> 3
order_line.write({
'product_uom_qty': 3.0,
'price_unit': 100.0,
})
order.invalidate_recordset(['amount_undiscounted'])
self.assertEqual(order.amount_undiscounted, 300.0)
self.assertEqual(order_line.price_subtotal, 297.0)
# undiscounted
order_line.discount = 0.0
self.assertEqual(order_line.price_subtotal, 300.0)
self.assertEqual(order.amount_undiscounted, 300.0)
# Same with an included-in-price tax
order = order.copy()
line = order.order_line
line.tax_id = [Command.create({
'name': 'Super Tax',
'amount_type': 'percent',
'amount': 10.0,
'price_include': True,
})]
line.discount = 50.0
order.action_confirm()
# 300 with 10% incl tax -> 272.72 total tax excluded without discount
# 136.36 price tax excluded with discount applied
self.assertEqual(order.amount_undiscounted, 272.72)
self.assertEqual(line.price_subtotal, 136.36)
def test_product_quantity_rounding(self):
"""When adding a sale order line, product quantity should be rounded
according to decimal precision"""
order = self.empty_order
product_uom_qty = 0.333333
order.order_line = [Command.create({
'product_id': self.product.id,
'product_uom_qty': product_uom_qty,
'price_unit': 75.0,
})]
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)
self.assertAlmostEqual(line.price_subtotal, expected_price_subtotal)
self.assertEqual(order.amount_total, order.tax_totals.get('amount_total'))

View file

@ -0,0 +1,521 @@
# -*- 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,
})
@tagged('post_install', '-at_install')
class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueCommon):
def _setup_pricelist(self, currency_ratio=2):
to_currency = self._setup_currency(currency_ratio)
discount = 10
pricelist = self.env['product.pricelist'].create({
'name': 'test pl',
'currency_id': to_currency.id,
'company_id': self.computer.company_id.id,
})
pricelist_item = self.env['product.pricelist.item'].create({
'min_quantity': 2,
'compute_price': 'percentage',
'percent_price': discount,
'pricelist_id': pricelist.id,
})
return (pricelist, pricelist_item, currency_ratio, 1 - discount / 100)
def test_01_is_combination_possible_archived(self):
"""The goal is to test the possibility of archived combinations.
This test could not be put into product module because there was no
model which had product_id as required and without cascade on delete.
Here we have the sales order line in this situation.
This is a necessary condition for `_create_variant_ids` to archive
instead of delete the variants.
"""
def do_test(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
variant2 = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_2)
self.assertTrue(variant)
self.assertTrue(variant2)
# Create a dummy SO to prevent the variant from being deleted by
# _create_variant_ids() because the variant is a related field that
# is required on the SO line
so = self.env['sale.order'].create({'partner_id': 1})
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant.id
})
# additional variant to test correct ignoring when mismatch values
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant2.id
})
variant2.active = False
# CASE: 1 not archived, 2 archived
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
# CASE: both archived combination (without no_variant)
variant.active = False
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: OK after attribute line removed
self.computer_hdd_attribute_lines.write({'active': False})
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8))
# CASE: not archived (with no_variant)
self.hdd_attribute.create_variant = 'no_variant'
self._add_hdd_attribute_line()
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: archived combination found (with no_variant)
variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
variant.active = False
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: archived combination has different attributes (including no_variant)
self.computer_ssd_attribute_lines.write({'active': False})
variant4 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant4.id
})
self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
# CASE: archived combination has different attributes (without no_variant)
self.computer_hdd_attribute_lines.write({'active': False})
self.hdd_attribute.create_variant = 'always'
self._add_hdd_attribute_line()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
variant5 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant5.id
})
self.assertTrue(variant4 != variant5)
self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
computer_ssd_256_before = self._get_product_template_attribute_value(self.ssd_256)
do_test(self)
# CASE: add back the removed attribute and try everything again
self.computer_ssd_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.ssd_attribute.id,
'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])],
})
computer_ssd_256_after = self._get_product_template_attribute_value(self.ssd_256)
self.assertEqual(computer_ssd_256_after, computer_ssd_256_before)
self.assertEqual(computer_ssd_256_after.attribute_line_id, computer_ssd_256_before.attribute_line_id)
do_test(self)
def test_02_get_combination_info(self):
# If using multi-company, company_id will be False, and this code should
# still work.
# The case with a company_id will be implicitly tested on website_sale.
self.computer.company_id = False
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
# CASE: no pricelist, no currency, with existing combination, with price_extra on attributes
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
computer_variant = self.computer._get_variant_for_combination(combination)
res = self.computer._get_combination_info(combination)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant.id)
self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
self.assertEqual(res['price'], 2222)
self.assertEqual(res['list_price'], 2222)
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,405 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import Form, tagged
from odoo.addons.sale.tests.common import TestSaleCommon
@tagged('post_install', '-at_install')
class TestSaleRefund(TestSaleCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
# Create the SO with four order lines
cls.sale_order = cls.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': cls.partner_a.id,
'partner_invoice_id': cls.partner_a.id,
'partner_shipping_id': cls.partner_a.id,
'pricelist_id': cls.company_data['default_pricelist'].id,
'order_line': [
Command.create({
'product_id': cls.company_data['product_order_no'].id,
'product_uom_qty': 5,
'tax_id': False,
}),
Command.create({
'product_id': cls.company_data['product_service_delivery'].id,
'product_uom_qty': 4,
'tax_id': False,
}),
Command.create({
'product_id': cls.company_data['product_service_order'].id,
'product_uom_qty': 3,
'tax_id': False,
}),
Command.create({
'product_id': cls.company_data['product_delivery_no'].id,
'product_uom_qty': 2,
'tax_id': False,
}),
]
})
(
cls.sol_prod_order,
cls.sol_serv_deliver,
cls.sol_serv_order,
cls.sol_prod_deliver,
) = cls.sale_order.order_line
# Confirm the SO
cls.sale_order.action_confirm()
# Create an invoice with invoiceable lines only
payment = cls.env['sale.advance.payment.inv'].with_context({
'active_model': 'sale.order',
'active_ids': [cls.sale_order.id],
'active_id': cls.sale_order.id,
'default_journal_id': cls.company_data['default_journal_sale'].id,
}).create({
'advance_payment_method': 'delivered'
})
payment.create_invoices()
cls.invoice = cls.sale_order.invoice_ids[0]
def test_refund_create(self):
# Validate invoice
self.invoice.action_post()
# Check quantity to invoice on SO lines
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
self.assertFalse(line.invoice_lines, "The line based on delivered qty are not invoiced, so they should not be linked to invoice line")
else:
if line == self.sol_prod_order:
self.assertEqual(line.qty_to_invoice, 0.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
self.assertEqual(line.qty_invoiced, 5.0, "The ordered (prod) sale line are totally invoiced (qty invoiced come from the invoice lines)")
else:
self.assertEqual(line.qty_to_invoice, 0.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
self.assertEqual(line.qty_invoiced, 3.0, "The ordered (serv) sale line are totally invoiced (qty invoiced = the invoice lines)")
self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products")
self.assertEqual(len(line.invoice_lines), 1, "The lines 'ordered' qty are invoiced, so it should be linked to 1 invoice lines")
# Make a credit note
credit_note_wizard = self.env['account.move.reversal'].with_context({'active_ids': [self.invoice.id], 'active_id': self.invoice.id, 'active_model': 'account.move'}).create({
'refund_method': 'refund', # this is the only mode for which the SO line is linked to the refund (https://github.com/odoo/odoo/commit/e680f29560ac20133c7af0c6364c6ef494662eac)
'reason': 'reason test create',
'journal_id': self.invoice.journal_id.id,
})
credit_note_wizard.reverse_moves()
invoice_refund = self.sale_order.invoice_ids.sorted(key=lambda inv: inv.id, reverse=False)[-1] # the first invoice, its refund, and the new invoice
# Check invoice's type and number
self.assertEqual(invoice_refund.move_type, 'out_refund', 'The last created invoiced should be a refund')
self.assertEqual(invoice_refund.state, 'draft', 'Last Customer invoices should be in draft')
self.assertEqual(self.sale_order.invoice_count, 2, "The SO should have 2 related invoices: the original, the new refund")
self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_refund')), 1, "The SO should be linked to only one refund")
self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_invoice')), 1, "The SO should be linked to only one customer invoices")
# At this time, the invoice 1 is opend (validated) and its refund is in draft, so the amounts invoiced are not zero for
# invoiced sale line. The amounts only take validated invoice/refund into account.
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO line based on delivered qty")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
self.assertFalse(line.invoice_lines, "The line based on delivered are not invoiced, so they should not be linked to invoice line")
else:
if line == self.sol_prod_order:
self.assertEqual(line.qty_to_invoice, 5.0, "As the refund is created, the invoiced quantity cancel each other (consu ordered)")
self.assertEqual(line.qty_invoiced, 0.0, "The qty to invoice should have decreased as the refund is created for ordered consu SO line")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "Amount to invoice is zero as the refund is not validated")
self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * 5, "Amount invoiced is now set as unit price * ordered qty - refund qty) even if the ")
self.assertEqual(len(line.invoice_lines), 2, "The line 'ordered consumable' is invoiced, so it should be linked to 2 invoice lines (invoice and refund)")
else:
self.assertEqual(line.qty_to_invoice, 3.0, "As the refund is created, the invoiced quantity cancel each other (consu ordered)")
self.assertEqual(line.qty_invoiced, 0.0, "The qty to invoice should have decreased as the refund is created for ordered service SO line")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "Amount to invoice is zero as the refund is not validated")
self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * 3, "Amount invoiced is now set as unit price * ordered qty - refund qty) even if the ")
self.assertEqual(len(line.invoice_lines), 2, "The line 'ordered service' is invoiced, so it should be linked to 2 invoice lines (invoice and refund)")
# Validate the refund
invoice_refund.action_post()
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
self.assertFalse(line.invoice_lines, "The line based on delivered are not invoiced, so they should not be linked to invoice line")
else:
if line == self.sol_prod_order:
self.assertEqual(line.qty_to_invoice, 5.0, "As the refund still exists, the quantity to invoice is the ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "The qty to invoice should be zero as, with the refund, the quantities cancel each other")
self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * 5, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, as refund is validated")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "Amount invoiced decreased as the refund is now confirmed")
self.assertEqual(len(line.invoice_lines), 2, "The line 'ordered consumable' is invoiced, so it should be linked to 2 invoice lines (invoice and refund)")
else:
self.assertEqual(line.qty_to_invoice, 3.0, "As the refund still exists, the quantity to invoice is the ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "The qty to invoice should be zero as, with the refund, the quantities cancel each other")
self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * 3, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, as refund is validated")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "Amount invoiced decreased as the refund is now confirmed")
self.assertEqual(len(line.invoice_lines), 2, "The line 'ordered service' is invoiced, so it should be linked to 2 invoice lines (invoice and refund)")
def test_refund_cancel(self):
""" Test invoice with a refund in 'cancel' mode, meaning a refund will be created and auto confirm to completely cancel the first
customer invoice. The SO will have 2 invoice (customer + refund) in a paid state at the end. """
# Increase quantity of an invoice lines
with Form(self.invoice) as invoice_form:
with invoice_form.invoice_line_ids.edit(0) as line_form:
line_form.quantity = 6
with invoice_form.invoice_line_ids.edit(1) as line_form:
line_form.quantity = 4
# Validate invoice
self.invoice.action_post()
# Check quantity to invoice on SO lines
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
self.assertFalse(line.invoice_lines, "The line based on delivered qty are not invoiced, so they should not be linked to invoice line")
else:
self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products")
self.assertEqual(len(line.invoice_lines), 1, "The lines 'ordered' qty are invoiced, so it should be linked to 1 invoice lines")
self.assertEqual(line.qty_invoiced, line.product_uom_qty + 1, "The quantity invoiced is +1 unit from the one of the sale line, as we modified invoice lines (%s)" % (line.name,))
self.assertEqual(line.qty_to_invoice, -1, "The quantity to invoice is negative as we invoice more than ordered")
# Make a credit note
credit_note_wizard = self.env['account.move.reversal'].with_context({'active_ids': self.invoice.ids, 'active_id': self.invoice.id, 'active_model': 'account.move'}).create({
'refund_method': 'cancel',
'reason': 'reason test cancel',
'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
with Form(self.invoice) as invoice_form:
with invoice_form.invoice_line_ids.edit(0) as line_form:
line_form.quantity = 3
with invoice_form.invoice_line_ids.edit(1) as line_form:
line_form.quantity = 2
# Validate invoice
self.invoice.action_post()
# Check quantity to invoice on SO lines
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
self.assertFalse(line.invoice_lines, "The line based on delivered qty are not invoiced, so they should not be linked to invoice line")
else:
if line == self.sol_prod_order:
self.assertEqual(line.qty_to_invoice, 2.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
self.assertEqual(line.qty_invoiced, 3.0, "The ordered (prod) sale line are totally invoiced (qty invoiced come from the invoice lines)")
else:
self.assertEqual(line.qty_to_invoice, 1.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
self.assertEqual(line.qty_invoiced, 2.0, "The ordered (serv) sale line are totally invoiced (qty invoiced = the invoice lines)")
self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products")
self.assertEqual(len(line.invoice_lines), 1, "The lines 'ordered' qty are invoiced, so it should be linked to 1 invoice lines")
# Make a credit note
credit_note_wizard = self.env['account.move.reversal'].with_context({'active_ids': [self.invoice.id], 'active_id': self.invoice.id, 'active_model': 'account.move'}).create({
'refund_method': 'modify', # this is the only mode for which the SO line is linked to the refund (https://github.com/odoo/odoo/commit/e680f29560ac20133c7af0c6364c6ef494662eac)
'reason': 'reason test modify',
'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_invoice', 'The last created invoiced should be a customer invoice')
self.assertEqual(invoice_refund.state, 'draft', 'Last Customer invoices should be in draft')
self.assertEqual(self.sale_order.invoice_count, 3, "The SO should have 3 related invoices: the original, the refund, and the new one")
self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_refund')), 1, "The SO should be linked to only one refund")
self.assertEqual(len(self.sale_order.invoice_ids.filtered(lambda inv: inv.move_type == 'out_invoice')), 2, "The SO should be linked to two customer invoices")
# At this time, the invoice 1 and its refund are confirmed, so the amounts invoiced are zero. The third invoice
# (2nd customer inv) is in draft state.
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
self.assertFalse(line.invoice_lines, "The line based on delivered are not invoiced, so they should not be linked to invoice line")
else:
if line == self.sol_prod_order:
self.assertEqual(line.qty_to_invoice, 2.0, "The qty to invoice does not change when confirming the new invoice (2)")
self.assertEqual(line.qty_invoiced, 3.0, "The ordered (prod) sale line does not change on invoice 2 confirmation")
self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * 5, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "Amount invoiced is zero as the invoice 1 and its refund are reconcilied")
self.assertEqual(len(line.invoice_lines), 3, "The line 'ordered consumable' is invoiced, so it should be linked to 3 invoice lines (invoice and refund)")
else:
self.assertEqual(line.qty_to_invoice, 1.0, "The qty to invoice does not change when confirming the new invoice (2)")
self.assertEqual(line.qty_invoiced, 2.0, "The ordered (serv) sale line does not change on invoice 2 confirmation")
self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * 3, "Amount to invoice is now set as unit price * ordered qty - refund qty) even if the ")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "Amount invoiced is zero as the invoice 1 and its refund are reconcilied")
self.assertEqual(len(line.invoice_lines), 3, "The line 'ordered service' is invoiced, so it should be linked to 3 invoice lines (invoice and refund)")
# Change unit of ordered product on refund lines
move_form = Form(invoice_refund)
with move_form.invoice_line_ids.edit(0) as line_form:
line_form.price_unit = 100
with move_form.invoice_line_ids.edit(1) as line_form:
line_form.price_unit = 50
invoice_refund = move_form.save()
# Validate the refund
invoice_refund.action_post()
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
self.assertFalse(line.invoice_lines, "The line based on delivered are not invoiced, so they should not be linked to invoice line, even after validation")
else:
if line == self.sol_prod_order:
self.assertEqual(line.qty_to_invoice, 2.0, "The qty to invoice does not change when confirming the new invoice (3)")
self.assertEqual(line.qty_invoiced, 3.0, "The ordered sale line are totally invoiced (qty invoiced = ordered qty)")
self.assertEqual(line.untaxed_amount_to_invoice, 1100.0, "")
self.assertEqual(line.untaxed_amount_invoiced, 300.0, "")
self.assertEqual(len(line.invoice_lines), 3, "The line 'ordered consumable' is invoiced, so it should be linked to 2 invoice lines (invoice and refund), even after validation")
else:
self.assertEqual(line.qty_to_invoice, 1.0, "The qty to invoice does not change when confirming the new invoice (3)")
self.assertEqual(line.qty_invoiced, 2.0, "The ordered sale line are totally invoiced (qty invoiced = ordered qty)")
self.assertEqual(line.untaxed_amount_to_invoice, 170.0, "")
self.assertEqual(line.untaxed_amount_invoiced, 100.0, "")
self.assertEqual(len(line.invoice_lines), 3, "The line 'ordered service' is invoiced, so it should be linked to 2 invoice lines (invoice and refund), even after validation")
def test_refund_invoice_with_downpayment(self):
sale_order_refund = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'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,
})
self.assertRecordValues(sol_product, [{
'price_unit': 280.0,
'discount': 0.0,
'product_uom_qty': 5.0,
'qty_to_invoice': 0.0,
'invoice_status': 'no',
}])
sale_order_refund.action_confirm()
self.assertEqual(sol_product.qty_to_invoice, 5.0)
self.assertEqual(sol_product.invoice_status, 'to invoice')
so_context = {
'active_model': 'sale.order',
'active_ids': [sale_order_refund.id],
'active_id': sale_order_refund.id,
'default_journal_id': self.company_data['default_journal_sale'].id,
}
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
sol_downpayment = sale_order_refund.order_line[2]
dp_invoice = sale_order_refund.invoice_ids[0]
dp_invoice.action_post()
self.assertRecordValues(sol_downpayment, [{
'price_unit': 700.0,
'discount': 0.0,
'invoice_status': 'to invoice',
'untaxed_amount_to_invoice': -700.0,
'untaxed_amount_invoiced': 700.0,
'product_uom_qty': 0.0,
'qty_invoiced': 1.0,
'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.create_invoices()
so_invoice = max(sale_order_refund.invoice_ids)
self.assertEqual(len(so_invoice.invoice_line_ids.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))),
len(sale_order_refund.order_line.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))), 'All lines should be invoiced')
self.assertEqual(len(so_invoice.invoice_line_ids.filtered(lambda l: l.display_type == 'line_section' and l.name == "Down Payments")), 1, 'A single section for downpayments should be present')
self.assertEqual(so_invoice.amount_total, sale_order_refund.amount_total - sol_downpayment.price_unit, 'Downpayment should be applied')
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()
invoice_refund = sale_order_refund.invoice_ids.sorted(key=lambda inv: inv.id, reverse=False)[-1]
invoice_refund.action_post()
self.assertEqual(sol_product.qty_to_invoice, 5.0, "As the refund still exists, the quantity to invoice is the ordered quantity")
self.assertEqual(sol_product.qty_invoiced, 0.0, "The qty invoiced should be zero as, with the refund, the quantities cancel each other")
self.assertEqual(sol_product.untaxed_amount_to_invoice, sol_product.price_unit * 5, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, as refund is validated")
self.assertEqual(sol_product.untaxed_amount_invoiced, 0.0, "Amount invoiced decreased as the refund is now confirmed")
self.assertEqual(len(sol_product.invoice_lines), 2, "The product line is invoiced, so it should be linked to 2 invoice lines (invoice and refund)")
self.assertEqual(sol_downpayment.qty_to_invoice, -1.0, "As the downpayment was invoiced separately, it will still have to be deducted from the total invoice (hence -1.0), after the refund.")
self.assertEqual(sol_downpayment.qty_invoiced, 1.0, "The qty to invoice should be 1 as, with the refund, the products are not invoiced anymore, but the downpayment still is")
self.assertEqual(sol_downpayment.untaxed_amount_to_invoice, -(sol_product.price_unit * 5)/2, "Amount to invoice decreased as the refund is now confirmed")
self.assertEqual(sol_downpayment.untaxed_amount_invoiced, (sol_product.price_unit * 5)/2, "Amount invoiced is now set as half of all products' total amount to invoice, as refund is validated")
self.assertEqual(len(sol_downpayment.invoice_lines), 3, "The product line is invoiced, so it should be linked to 3 invoice lines (downpayment invoice, partial invoice and refund)")

View file

@ -0,0 +1,127 @@
# -*- 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.sale.tests.common import SaleCommon
@tagged('-at_install', 'post_install')
class TestSaleReportCurrencyRate(SaleCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.usd_cmp = cls.env['res.company'].create({
'name': 'USD Company', 'currency_id': cls.env.ref('base.USD').id,
})
cls.eur_cmp = cls.env['res.company'].create({
'name': 'EUR Company', 'currency_id': cls.env.ref('base.EUR').id,
})
def test_sale_report_foreign_currency(self):
"""
Test that amounts are correctly converted between currencies.
There are two different conversions to take into account:
- currency of the sale order pricelist -> currency of the sale order company
- currency of sale order company -> currency of the current user company
Adjustment between past and present rates must also be taken into account.
"""
companies = self.usd_cmp + self.eur_cmp
today = fields.Date.today()
past_day = fields.Date.to_date('2020-01-01')
usd = self.usd_cmp.currency_id
eur = self.eur_cmp.currency_id
ars = self._enable_currency('ARS')
# 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},
])
self.env['res.currency.rate'].create([
{'name': past_day, 'rate': 555, 'currency_id': ars.id, 'company_id': self.eur_cmp.id},
{'name': past_day, 'rate': 1.0, 'currency_id': eur.id, 'company_id': self.eur_cmp.id},
{'name': past_day, 'rate': 999, 'currency_id': usd.id, 'company_id': self.eur_cmp.id},
{'name': past_day, 'rate': 3.0, 'currency_id': ars.id, 'company_id': self.usd_cmp.id},
{'name': past_day, 'rate': 0.1, 'currency_id': eur.id, 'company_id': self.usd_cmp.id},
{'name': past_day, 'rate': 1.0, 'currency_id': usd.id, 'company_id': self.usd_cmp.id},
{'name': today, 'rate': 222, 'currency_id': ars.id, 'company_id': self.eur_cmp.id},
{'name': today, 'rate': 1.0, 'currency_id': eur.id, 'company_id': self.eur_cmp.id},
{'name': today, 'rate': 2.9, 'currency_id': usd.id, 'company_id': self.eur_cmp.id},
{'name': today, 'rate': 101, 'currency_id': ars.id, 'company_id': self.usd_cmp.id},
{'name': today, 'rate': 0.6, 'currency_id': eur.id, 'company_id': self.usd_cmp.id},
{'name': today, 'rate': 1.0, 'currency_id': usd.id, 'company_id': self.usd_cmp.id},
])
self.assertEqual(self.product.currency_id, usd)
# Needed to get conversion rates between companies.
currency_rates = (companies + self.env.company).mapped('currency_id')._get_rates(
self.env.company, today
)
sale_orders = self.env['sale.order']
expected_reported_amount = 0 # The total amount of all sale orders in the report.
qty = 0 # to add variety to the data
# Create sale orders
for company in companies:
SaleOrder = self.env['sale.order'].with_company(company)
for date in (past_day, today):
for pricelist in pricelists:
qty += 1
order = SaleOrder.create({
'partner_id': self.partner.id,
'pricelist_id': pricelist.id,
'date_order': date,
'order_line': [Command.create(
{'product_id': self.product.id, 'product_uom_qty': qty}
)],
})
sale_orders |= order
expected_so_currency_rate = self.env['res.currency.rate'].search([
('name', '=', date),
('currency_id', '=', pricelist.currency_id.id),
('company_id', '=', company.id),
]).rate
expected_product_currency_rate = self.env['res.currency.rate'].search([
('name', '=', date),
('currency_id', '=', self.product.currency_id.id),
('company_id', '=', company.id),
]).rate
# To find the total amount we convert the price of the product from its currency
# 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
self.assertAlmostEqual(order.currency_rate, expected_so_currency_rate)
self.assertAlmostEqual(order.amount_total, expected_amount_total)
# The amount in the report is converted first to the currency of the company and
# then to the currency of the current company (self.env.company).
current_company_rate = currency_rates[self.env.company.currency_id.id]
so_company_rate = currency_rates[company.currency_id.id]
conversion_rate = (current_company_rate / so_company_rate)
expected_reported_amount += (
order.amount_total / order.currency_rate * conversion_rate
)
# 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)])
price_total = sum(report_lines.mapped('price_total'))
self.assertEqual(price_total, expected_reported_amount)

View file

@ -0,0 +1,35 @@
# -*- 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,
})

View file

@ -0,0 +1,955 @@
# -*- 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 Form, tagged
from odoo.tools import float_is_zero
from odoo.addons.sale.tests.common import TestSaleCommon
@tagged('-at_install', 'post_install')
class TestSaleToInvoice(TestSaleCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
# Create the SO with four order lines
cls.sale_order = cls.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': cls.partner_a.id,
'partner_invoice_id': cls.partner_a.id,
'partner_shipping_id': cls.partner_a.id,
'pricelist_id': cls.company_data['default_pricelist'].id,
'order_line': [
Command.create({
'product_id': cls.company_data['product_order_no'].id,
'product_uom_qty': 5,
'tax_id': False,
}),
Command.create({
'product_id': cls.company_data['product_service_delivery'].id,
'product_uom_qty': 4,
'tax_id': False,
}),
Command.create({
'product_id': cls.company_data['product_service_order'].id,
'product_uom_qty': 3,
'tax_id': False,
}),
Command.create({
'product_id': cls.company_data['product_delivery_no'].id,
'product_uom_qty': 2,
'tax_id': False,
}),
]
})
(
cls.sol_prod_order,
cls.sol_serv_deliver,
cls.sol_serv_order,
cls.sol_prod_deliver,
) = cls.sale_order.order_line
# Context
cls.context = {
'active_model': 'sale.order',
'active_ids': [cls.sale_order.id],
'active_id': cls.sale_order.id,
'default_journal_id': cls.company_data['default_journal_sale'].id,
}
def _check_order_search(self, orders, domain, expected_result):
domain += [('id', 'in', orders.ids)]
result = self.env['sale.order'].search(domain)
self.assertEqual(result, expected_result, "Unexpected result on search orders")
def test_search_invoice_ids(self):
"""Test searching on computed fields invoice_ids"""
# Make qty zero to have a line without invoices
self.sol_prod_order.product_uom_qty = 0
self.sale_order.action_confirm()
# Tests before creating an invoice
self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order)
self._check_order_search(self.sale_order, [('invoice_ids', '!=', False)], self.env['sale.order'])
# Create invoice
moves = self.sale_order._create_invoices()
# Tests after creating the invoice
self._check_order_search(self.sale_order, [('invoice_ids', 'in', moves.ids)], self.sale_order)
self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order'])
self._check_order_search(self.sale_order, [('invoice_ids', '!=', False)], self.sale_order)
def test_downpayment(self):
""" Test invoice with a way of downpayment and check downpayment's SO line is created
and also check a total amount of invoice is equal to a respective sale order's total amount
"""
# Confirm the SO
self.sale_order.action_confirm()
self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order)
# Let's do an invoice for a deposit of 100
downpayment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'fixed',
'fixed_amount': 50,
'deposit_account_id': self.company_data['default_account_revenue'].id
})
downpayment.create_invoices()
downpayment2 = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'fixed',
'fixed_amount': 50,
'deposit_account_id': self.company_data['default_account_revenue'].id
})
downpayment2.create_invoices()
self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order'])
self.assertEqual(len(self.sale_order.invoice_ids), 2, 'Invoice should be created for the SO')
downpayment_line = self.sale_order.order_line.filtered(lambda l: l.is_downpayment and not l.display_type)
self.assertEqual(len(downpayment_line), 2, 'SO line downpayment should be created on SO')
# Update delivered quantity of SO lines
self.sol_serv_deliver.write({'qty_delivered': 4.0})
self.sol_prod_deliver.write({'qty_delivered': 2.0})
# Let's do an invoice with refunds
payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'deposit_account_id': self.company_data['default_account_revenue'].id
})
payment.create_invoices()
self.assertEqual(len(self.sale_order.invoice_ids), 3, 'Invoice should be created for the SO')
invoice = max(self.sale_order.invoice_ids)
self.assertEqual(len(invoice.invoice_line_ids.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))),
len(self.sale_order.order_line.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))), 'All lines should be invoiced')
self.assertEqual(len(invoice.invoice_line_ids.filtered(lambda l: l.display_type == 'line_section' and l.name == "Down Payments")), 1, 'A single section for downpayments should be present')
self.assertEqual(invoice.amount_total, self.sale_order.amount_total - sum(downpayment_line.mapped('price_unit')), 'Downpayment should be applied')
def test_downpayment_validation(self):
""" Test invoice for downpayment and check it can be validated
"""
# Lock the sale orders when confirmed
self.env.user.groups_id += self.env.ref('sale.group_auto_done_setting')
# Confirm the SO
self.sale_order.action_confirm()
self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order)
# Let's do an invoice for a deposit of 10%
downpayment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'percentage',
'amount': 10,
'deposit_account_id': self.company_data['default_account_revenue'].id
})
downpayment.create_invoices()
self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order'])
# Update delivered quantity of SO lines
self.sol_serv_deliver.write({'qty_delivered': 4.0})
self.sol_prod_deliver.write({'qty_delivered': 2.0})
# Validate invoice
self.sale_order.invoice_ids.action_post()
def test_downpayment_line_remains_on_SO(self):
""" Test downpayment's SO line is created and remains unchanged even if everything is invoiced
"""
# Create the SO with one line
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'pricelist_id': self.company_data['default_pricelist'].id,
'order_line': [Command.create({
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 5,
'tax_id': False,
}),]
})
# Confirm the SO
sale_order.action_confirm()
# Update delivered quantity of SO line
sale_order.order_line.write({'qty_delivered': 5.0})
context = {
'active_model': 'sale.order',
'active_ids': [sale_order.id],
'active_id': sale_order.id,
'default_journal_id': self.company_data['default_journal_sale'].id,
}
# Let's do an invoice for a down payment of 50
downpayment = self.env['sale.advance.payment.inv'].with_context(context).create({
'advance_payment_method': 'fixed',
'fixed_amount': 50,
'deposit_account_id': self.company_data['default_account_revenue'].id
})
downpayment.create_invoices()
# Let's do the invoice for the remaining amount
payment = self.env['sale.advance.payment.inv'].with_context(context).create({
'deposit_account_id': self.company_data['default_account_revenue'].id
})
payment.create_invoices()
downpayment_line = sale_order.order_line.filtered(lambda l: l.is_downpayment and not l.display_type)
self.assertEqual(downpayment_line[0].price_unit, 50, 'The down payment unit price should not change on SO')
# Confirm all invoices
sale_order.invoice_ids.action_post()
self.assertEqual(downpayment_line[0].price_unit, 50, 'The down payment unit price should not change on SO')
def test_downpayment_percentage_tax_icl(self):
""" Test invoice with a percentage downpayment and an included tax
Check the total amount of invoice is correct and equal to a respective sale order's total amount
"""
# Confirm the SO
self.sale_order.action_confirm()
tax_downpayment = self.company_data['default_tax_sale'].copy({'price_include': True})
# Let's do an invoice for a deposit of 100
product_id = self.env['ir.config_parameter'].sudo().get_param('sale.default_deposit_product_id')
product_id = self.env['product.product'].browse(int(product_id)).exists()
product_id.taxes_id = tax_downpayment.ids
payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'percentage',
'amount': 50,
'deposit_account_id': self.company_data['default_account_revenue'].id,
})
payment.create_invoices()
self.assertEqual(len(self.sale_order.invoice_ids), 1, 'Invoice should be created for the SO')
downpayment_line = self.sale_order.order_line.filtered(lambda l: l.is_downpayment and not l.display_type)
self.assertEqual(len(downpayment_line), 1, 'SO line downpayment should be created on SO')
self.assertEqual(downpayment_line.price_unit, self.sale_order.amount_total/2, 'downpayment should have the correct amount')
invoice = self.sale_order.invoice_ids[0]
downpayment_aml = invoice.line_ids.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))[0]
self.assertEqual(downpayment_aml.price_total, self.sale_order.amount_total/2, 'downpayment should have the correct amount')
self.assertEqual(downpayment_aml.price_unit, self.sale_order.amount_total/2, 'downpayment should have the correct amount')
invoice.action_post()
self.assertEqual(downpayment_line.price_unit, self.sale_order.amount_total/2, 'downpayment should have the correct amount')
def test_downpayment_invoice_and_partial_credit_note(self):
"""This test check that the downpayment line amount on the sale order remains consistent"""
self.sale_order.action_confirm()
# Create an invoice for a Down payment of 100
payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'fixed',
'fixed_amount': 100,
'deposit_account_id': self.company_data['default_account_revenue'].id,
})
payment.create_invoices()
# Ensure the downpayment line on the sale order is correctly set to 100
downpayment_line = self.sale_order.order_line.filtered(lambda l: l.is_downpayment and not l.display_type)
self.assertEqual(downpayment_line.price_unit, 100)
# post the downpayment invoice and ensure the downpayment_line amount is still 100
downpayment_invoice = downpayment_line.order_id.order_line.invoice_lines.move_id
downpayment_invoice.action_post()
self.assertEqual(downpayment_line.price_unit, 100)
# Create a credit note for a part of the downpayment invoice and post it
move_reversal = self.env['account.move.reversal'].with_context(
active_model="account.move",
active_ids=downpayment_invoice.ids,
).create({
'date': '2020-02-01',
'reason': 'no reason',
'refund_method': 'refund',
'journal_id': downpayment_invoice.journal_id.id,
})
reversal_action = move_reversal.reverse_moves()
reverse_move = self.env['account.move'].browse(reversal_action['res_id'])
with Form(reverse_move) as form_reverse:
with form_reverse.invoice_line_ids.edit(0) as line_form:
line_form.price_unit = 20.0
reverse_move.action_post()
self.assertEqual(downpayment_line.price_unit, 80,
"The downpayment line amount should be equal to the sum of the invoice and credit note amount")
def test_invoice_with_discount(self):
""" Test invoice with a discount and check discount applied on both SO lines and an invoice lines """
# Update discount and delivered quantity on SO lines
self.sol_prod_order.write({'discount': 20.0})
self.sol_serv_deliver.write({'discount': 20.0, 'qty_delivered': 4.0})
self.sol_serv_order.write({'discount': -10.0})
self.sol_prod_deliver.write({'qty_delivered': 2.0})
for line in self.sale_order.order_line.filtered(lambda l: l.discount):
product_price = line.price_unit * line.product_uom_qty
self.assertEqual(line.discount, (product_price - line.price_subtotal) / product_price * 100, 'Discount should be applied on order line')
# lines are in draft
for line in self.sale_order.order_line:
self.assertTrue(float_is_zero(line.untaxed_amount_to_invoice, precision_digits=2), "The amount to invoice should be zero, as the line is in draf state")
self.assertTrue(float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state")
self.sale_order.action_confirm()
for line in self.sale_order.order_line:
self.assertTrue(float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state")
self.assertEqual(self.sol_serv_order.untaxed_amount_to_invoice, 297, "The untaxed amount to invoice is wrong")
self.assertEqual(self.sol_serv_deliver.untaxed_amount_to_invoice, self.sol_serv_deliver.qty_delivered * self.sol_serv_deliver.price_reduce, "The untaxed amount to invoice should be qty deli * price reduce, so 4 * (180 - 36)")
# 'untaxed_amount_to_invoice' is invalid when 'sale_stock' is installed.
# self.assertEqual(self.sol_prod_deliver.untaxed_amount_to_invoice, 140, "The untaxed amount to invoice should be qty deli * price reduce, so 4 * (180 - 36)")
# Let's do an invoice with invoiceable lines
payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'delivered'
})
self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order)
payment.create_invoices()
self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order'])
invoice = self.sale_order.invoice_ids[0]
invoice.action_post()
# Check discount appeared on both SO lines and invoice lines
for line, inv_line in zip(self.sale_order.order_line, invoice.invoice_line_ids):
self.assertEqual(line.discount, inv_line.discount, 'Discount on lines of order and invoice should be same')
def test_invoice(self):
""" Test create and invoice from the SO, and check qty invoice/to invoice, and the related amounts """
# lines are in draft
for line in self.sale_order.order_line:
self.assertTrue(float_is_zero(line.untaxed_amount_to_invoice, precision_digits=2), "The amount to invoice should be zero, as the line is in draf state")
self.assertTrue(float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state")
# Confirm the SO
self.sale_order.action_confirm()
# Check ordered quantity, quantity to invoice and invoiced quantity of SO lines
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, 'Quantity to invoice should be same as ordered quantity')
self.assertEqual(line.qty_invoiced, 0.0, 'Invoiced quantity should be zero as no any invoice created for SO')
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
else:
self.assertEqual(line.qty_to_invoice, line.product_uom_qty, 'Quantity to invoice should be same as ordered quantity')
self.assertEqual(line.qty_invoiced, 0.0, 'Invoiced quantity should be zero as no any invoice created for SO')
self.assertEqual(line.untaxed_amount_to_invoice, line.product_uom_qty * line.price_unit, "The amount to invoice should the total of the line, as the line is confirmed")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line is confirmed")
# Let's do an invoice with invoiceable lines
payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'delivered'
})
payment.create_invoices()
invoice = self.sale_order.invoice_ids[0]
# Update quantity of an invoice lines
move_form = Form(invoice)
with move_form.invoice_line_ids.edit(0) as line_form:
line_form.quantity = 3.0
with move_form.invoice_line_ids.edit(1) as line_form:
line_form.quantity = 2.0
invoice = move_form.save()
# amount to invoice / invoiced should not have changed (amounts take only confirmed invoice into account)
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be zero")
self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as delivered lines are not delivered yet")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity (no confirmed invoice)")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as no invoice are validated for now")
else:
if line == self.sol_prod_order:
self.assertEqual(self.sol_prod_order.qty_to_invoice, 2.0, "Changing the quantity on draft invoice update the qty to invoice on SO lines")
self.assertEqual(self.sol_prod_order.qty_invoiced, 3.0, "Changing the quantity on draft invoice update the invoiced qty on SO lines")
else:
self.assertEqual(self.sol_serv_order.qty_to_invoice, 1.0, "Changing the quantity on draft invoice update the qty to invoice on SO lines")
self.assertEqual(self.sol_serv_order.qty_invoiced, 2.0, "Changing the quantity on draft invoice update the invoiced qty on SO lines")
self.assertEqual(line.untaxed_amount_to_invoice, line.product_uom_qty * line.price_unit, "The amount to invoice should the total of the line, as the line is confirmed (no confirmed invoice)")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as no invoice are validated for now")
invoice.action_post()
# Check quantity to invoice on SO lines
for line in self.sale_order.order_line:
if line.product_id.invoice_policy == 'delivery':
self.assertEqual(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity")
self.assertEqual(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO")
self.assertEqual(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity")
self.assertEqual(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity")
else:
if line == self.sol_prod_order:
self.assertEqual(line.qty_to_invoice, 2.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
self.assertEqual(line.qty_invoiced, 3.0, "The ordered (prod) sale line are totally invoiced (qty invoiced come from the invoice lines)")
else:
self.assertEqual(line.qty_to_invoice, 1.0, "The ordered sale line are totally invoiced (qty to invoice is zero)")
self.assertEqual(line.qty_invoiced, 2.0, "The ordered (serv) sale line are totally invoiced (qty invoiced = the invoice lines)")
self.assertEqual(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products")
self.assertEqual(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products")
def test_multiple_sale_orders_on_same_invoice(self):
""" The model allows the association of multiple SO lines linked to the same invoice line.
Check that the operations behave well, if a custom module creates such a situation.
"""
self.sale_order.action_confirm()
payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'delivered'
})
payment.create_invoices()
# create a second SO whose lines are linked to the same invoice lines
# this is a way to create a situation where sale_line_ids has multiple items
sale_order_data = self.sale_order.copy_data()[0]
sale_order_data['order_line'] = [
(0, 0, line.copy_data({
'invoice_lines': [(6, 0, line.invoice_lines.ids)],
})[0])
for line in self.sale_order.order_line
]
self.sale_order.create(sale_order_data)
# we should now have at least one move line linked to several order lines
invoice = self.sale_order.invoice_ids[0]
self.assertTrue(any(len(move_line.sale_line_ids) > 1
for move_line in invoice.line_ids))
# however these actions should not raise
invoice.action_post()
invoice.button_draft()
invoice.button_cancel()
def test_invoice_with_sections(self):
""" Test create and invoice with sections from the SO, and check qty invoice/to invoice, and the related amounts """
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'pricelist_id': self.company_data['default_pricelist'].id,
})
SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True)
SaleOrderLine.create({
'name': 'Section',
'display_type': 'line_section',
'order_id': sale_order.id,
})
sol_prod_deliver = SaleOrderLine.create({
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 5,
'order_id': sale_order.id,
'tax_id': False,
})
# Confirm the SO
sale_order.action_confirm()
sol_prod_deliver.write({'qty_delivered': 5.0})
# Context
self.context = {
'active_model': 'sale.order',
'active_ids': [sale_order.id],
'active_id': sale_order.id,
'default_journal_id': self.company_data['default_journal_sale'].id,
}
# Let's do an invoice with invoiceable lines
payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'delivered'
})
payment.create_invoices()
invoice = sale_order.invoice_ids[0]
self.assertEqual(invoice.line_ids[0].display_type, 'line_section')
def test_qty_invoiced(self):
"""Verify uom rounding is correctly considered during qty_invoiced compute"""
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'pricelist_id': self.company_data['default_pricelist'].id,
})
SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True)
sol_prod_deliver = SaleOrderLine.create({
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 5,
'order_id': sale_order.id,
'tax_id': False,
})
# Confirm the SO
sale_order.action_confirm()
sol_prod_deliver.write({'qty_delivered': 5.0})
# Context
self.context = {
'active_model': 'sale.order',
'active_ids': [sale_order.id],
'active_id': sale_order.id,
'default_journal_id': self.company_data['default_journal_sale'].id,
}
# Let's do an invoice with invoiceable lines
invoicing_wizard = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'delivered'
})
invoicing_wizard.create_invoices()
self.assertEqual(sol_prod_deliver.qty_invoiced, 5.0)
# We would have to change the digits of the field to
# test a greater decimal precision.
quantity = 5.13
move_form = Form(sale_order.invoice_ids)
with move_form.invoice_line_ids.edit(0) as line_form:
line_form.quantity = quantity
move_form.save()
# Default uom rounding to 0.01
qty_invoiced_field = sol_prod_deliver._fields.get('qty_invoiced')
sol_prod_deliver.env.add_to_compute(qty_invoiced_field, sol_prod_deliver)
self.assertEqual(sol_prod_deliver.qty_invoiced, quantity)
# Rounding to 0.1, should be rounded with UP (ceil) rounding_method
# Not floor or half up rounding.
sol_prod_deliver.product_uom.rounding *= 10
sol_prod_deliver.product_uom.flush_recordset(['rounding'])
expected_qty = 5.2
qty_invoiced_field = sol_prod_deliver._fields.get('qty_invoiced')
sol_prod_deliver.env.add_to_compute(qty_invoiced_field, sol_prod_deliver)
self.assertEqual(sol_prod_deliver.qty_invoiced, expected_qty)
def test_multi_company_invoice(self):
"""Checks that the company of the invoices generated in a multi company environment using the
'sale.advance.payment.inv' wizard fit with the company of the SO and not with the current company.
"""
so_company_id = self.sale_order.company_id.id
yet_another_company_id = self.company_data_2['company'].id
so_for_downpayment = self.sale_order.copy()
self.context.update(allowed_company_ids=[yet_another_company_id, self.env.company.id], company_id=yet_another_company_id)
context_for_downpayment = self.context.copy()
context_for_downpayment.update(active_ids=[so_for_downpayment.id], active_id=so_for_downpayment.id)
# Make sure the invoice is not created with a journal in the context
# Because it makes the test always succeed (by using the journal company instead of the env company)
no_journal_ctxt = dict(self.context)
no_journal_ctxt.pop('default_journal_id', None)
no_journal_ctxt.pop('journal_id', None)
self.sale_order.with_context(self.context).action_confirm()
payment = self.env['sale.advance.payment.inv'].with_context(no_journal_ctxt).create({
'advance_payment_method': 'percentage',
'amount': 50,
})
payment.create_invoices()
self.assertEqual(self.sale_order.invoice_ids[0].company_id.id, so_company_id, "The company of the invoice should be the same as the one from the SO")
so_for_downpayment.with_context(context_for_downpayment).action_confirm()
downpayment = self.env['sale.advance.payment.inv'].with_context(context_for_downpayment).create({
'advance_payment_method': 'fixed',
'fixed_amount': 50,
'deposit_account_id': self.company_data['default_account_revenue'].id
})
downpayment.create_invoices()
self.assertEqual(so_for_downpayment.invoice_ids[0].company_id.id, so_company_id, "The company of the downpayment invoice should be the same as the one from the SO")
def test_invoice_analytic_distribution_model(self):
""" Tests whether, when an analytic account rule is set and the so has no analytic account,
the default analytic account is correctly computed in the invoice.
"""
analytic_plan_default = self.env['account.analytic.plan'].create({'name': 'default'})
analytic_account_default = self.env['account.analytic.account'].create({'name': 'default', 'plan_id': analytic_plan_default.id})
self.env['account.analytic.distribution.model'].create({
'analytic_distribution': {analytic_account_default.id: 100},
'product_id': self.product_a.id,
})
so_form = Form(self.env['sale.order'])
so_form.partner_id = self.partner_a
with so_form.order_line.new() as sol:
sol.product_id = self.product_a
sol.product_uom_qty = 1
so = so_form.save()
so.action_confirm()
so._force_lines_to_invoice_policy_order()
so_context = {
'active_model': 'sale.order',
'active_ids': [so.id],
'active_id': so.id,
'default_journal_id': self.company_data['default_journal_sale'].id,
}
down_payment = self.env['sale.advance.payment.inv'].with_context(so_context).create({})
down_payment.create_invoices()
aml = self.env['account.move.line'].search([('move_id', 'in', so.invoice_ids.ids)])[0]
self.assertRecordValues(aml, [{'analytic_distribution': {str(analytic_account_default.id): 100}}])
def test_invoice_analytic_account_so_not_default(self):
""" Tests whether, when an analytic account rule is set and the so has an analytic account,
the default analytic acount doesn't replace the one from the so in the invoice.
"""
# Required for `analytic_account_id` to be visible in the view
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
analytic_plan_default = self.env['account.analytic.plan'].create({'name': 'default'})
analytic_account_default = self.env['account.analytic.account'].create({'name': 'default', 'plan_id': analytic_plan_default.id})
analytic_account_so = self.env['account.analytic.account'].create({'name': 'so', 'plan_id': analytic_plan_default.id})
self.env['account.analytic.distribution.model'].create({
'analytic_distribution': {analytic_account_default.id: 100},
'product_id': self.product_a.id,
})
so_form = Form(self.env['sale.order'])
so_form.partner_id = self.partner_a
so_form.analytic_account_id = analytic_account_so
with so_form.order_line.new() as sol:
sol.product_id = self.product_a
sol.product_uom_qty = 1
so = so_form.save()
so.action_confirm()
so._force_lines_to_invoice_policy_order()
so_context = {
'active_model': 'sale.order',
'active_ids': [so.id],
'active_id': so.id,
'default_journal_id': self.company_data['default_journal_sale'].id,
}
down_payment = self.env['sale.advance.payment.inv'].with_context(so_context).create({})
down_payment.create_invoices()
aml = self.env['account.move.line'].search([('move_id', 'in', so.invoice_ids.ids)])[0]
self.assertRecordValues(aml, [{'analytic_distribution': {str(analytic_account_default.id): 100, str(analytic_account_so.id): 100}}])
def test_invoice_analytic_rule_with_account_prefix(self):
"""
Test whether, when an analytic account rule is set within the scope (applicability) of invoice
and with an account prefix set,
the default analytic account is correctly set during the conversion from so to invoice
"""
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
analytic_plan_default = self.env['account.analytic.plan'].create({
'name': 'default',
'applicability_ids': [Command.create({
'business_domain': 'invoice',
'applicability': 'optional',
})]
})
analytic_account_default = self.env['account.analytic.account'].create({'name': 'default', 'plan_id': analytic_plan_default.id})
analytic_distribution_model = self.env['account.analytic.distribution.model'].create({
'account_prefix': '400000',
'analytic_distribution': {analytic_account_default.id: 100},
'product_id': self.product_a.id,
})
so = self.env['sale.order'].create({'partner_id': self.partner_a.id})
self.env['sale.order.line'].create({
'order_id': so.id,
'name': 'test',
'product_id': self.product_a.id
})
self.assertFalse(so.order_line.analytic_distribution, "There should be no tag set.")
so.action_confirm()
so.order_line.qty_delivered = 1
aml = so._create_invoices().invoice_line_ids
self.assertRecordValues(aml, [{'analytic_distribution': analytic_distribution_model.analytic_distribution}])
def test_invoice_after_product_return_price_not_default(self):
so = self.env['sale.order'].create({
'name': 'Sale order',
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'order_line': [
(0, 0, {'name': self.product_a.name, 'product_id': self.product_a.id, 'product_uom_qty': 1, 'price_unit': 123}),
]
})
self._check_order_search(so, [('invoice_ids', '=', False)], so)
so.action_confirm()
so_context = {
'active_model': 'sale.order',
'active_ids': [so.id],
'active_id': so.id,
'default_journal_id': self.company_data['default_journal_sale'].id,
}
invoicing_wizard = self.env['sale.advance.payment.inv'].with_context(so_context).create({})
invoicing_wizard.create_invoices()
self.assertTrue(so.invoice_ids, "The invoice was not created")
# simulating return by changing product_uom_qty to 0
so.order_line.product_uom_qty = 0
# checking if the price_unit is the same
self.assertEqual(so.order_line.price_unit, 123,
"The unit price should be the same as the one used to create the sales order line")
def test_group_invoice(self):
""" Test that invoicing multiple sales order for the same customer works. """
# Create 3 SOs for the same partner, one of which that uses another currency
eur_pricelist = self.env['product.pricelist'].create({'name': 'EUR', 'currency_id': self.env.ref('base.EUR').id})
so1 = self.sale_order.with_context(mail_notrack=True).copy()
so1.pricelist_id = eur_pricelist
so2 = so1.copy()
usd_pricelist = self.env['product.pricelist'].create({'name': 'USD', 'currency_id': self.env.ref('base.USD').id})
so3 = so1.copy()
so1.pricelist_id = usd_pricelist
orders = so1 | so2 | so3
orders.action_confirm()
# Create the invoicing wizard and invoice all of them at once
wiz = self.env['sale.advance.payment.inv'].with_context(active_ids=orders.ids, open_invoices=True).create({})
res = wiz.create_invoices()
# Check that exactly 2 invoices are generated
self.assertEqual(
len(res['domain'][0][2]),
2,
"Invoicing 3 orders for the same partner with 2 currencies"
"should create exactly 2 invoices.")
def test_so_note_to_invoice(self):
"""Test that notes from SO are pushed into invoices"""
self.sale_order.order_line = [Command.create({
'name': 'This is a note',
'display_type': 'line_note',
'product_id': False,
'product_uom_qty': 0,
'product_uom': False,
'price_unit': 0,
'order_id': self.sale_order.id,
'tax_id': False,
})]
# confirm quotation
self.sale_order.action_confirm()
# create invoice
invoice = self.sale_order._create_invoices()
# check note from SO has been pushed in invoice
self.assertEqual(
len(invoice.invoice_line_ids.filtered(lambda line: line.display_type == 'line_note')),
1,
'Note SO line should have been pushed to the invoice')
def test_cost_invoicing(self):
""" Test confirming a vendor invoice to reinvoice cost on the so """
serv_cost = self.env['product.product'].create({
'name': "Ordered at cost",
'standard_price': 160,
'list_price': 180,
'type': 'consu',
'invoice_policy': 'order',
'expense_policy': 'cost',
'default_code': 'PROD_COST',
'service_type': 'manual',
})
prod_gap = self.company_data['product_service_order']
so = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'order_line': [Command.create({
'product_id': prod_gap.id,
'product_uom_qty': 2,
'product_uom': prod_gap.uom_id.id,
'price_unit': prod_gap.list_price,
})],
'pricelist_id': self.company_data['default_pricelist'].id,
})
so.action_confirm()
so._create_analytic_account()
inv = self.env['account.move'].with_context(default_move_type='in_invoice').create({
'partner_id': self.partner_a.id,
'invoice_date': so.date_order,
'invoice_line_ids': [
Command.create({
'name': serv_cost.name,
'product_id': serv_cost.id,
'product_uom_id': serv_cost.uom_id.id,
'quantity': 2,
'price_unit': serv_cost.standard_price,
'analytic_distribution': {so.analytic_account_id.id: 100},
}),
],
})
inv.action_post()
sol = so.order_line.filtered(lambda l: l.product_id == serv_cost)
self.assertTrue(sol, 'Sale: cost invoicing does not add lines when confirming vendor invoice')
self.assertEqual(
(sol.price_unit, sol.qty_delivered, sol.product_uom_qty, sol.qty_invoiced),
(160, 2, 0, 0),
'Sale: line is wrong after confirming vendor invoice')
def test_sale_order_standard_flow_with_invoicing(self):
""" Test the sales order flow (invoicing and quantity updates)
- Invoice repeatedly while varrying delivered quantities and check that invoice are always what we expect
"""
self.sale_order.order_line.product_uom_qty = 2.0
# TODO?: validate invoice and register payments
self.sale_order.order_line.read(['name', 'price_unit', 'product_uom_qty', 'price_total'])
self.assertEqual(self.sale_order.amount_total, 1240.0, 'Sale: total amount is wrong')
self.sale_order.order_line._compute_product_updatable()
self.assertTrue(self.sale_order.order_line[0].product_updatable)
# send quotation
email_act = self.sale_order.action_quotation_send()
email_ctx = email_act.get('context', {})
self.sale_order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
self.assertTrue(self.sale_order.state == 'sent', 'Sale: state after sending is wrong')
self.sale_order.order_line._compute_product_updatable()
self.assertTrue(self.sale_order.order_line[0].product_updatable)
# confirm quotation
self.sale_order.action_confirm()
self.assertTrue(self.sale_order.state == 'sale')
self.assertTrue(self.sale_order.invoice_status == 'to invoice')
# create invoice: only 'invoice on order' products are invoiced
invoice = self.sale_order._create_invoices()
self.assertEqual(len(invoice.invoice_line_ids), 2, 'Sale: invoice is missing lines')
self.assertEqual(invoice.amount_total, 740.0, 'Sale: invoice total amount is wrong')
self.assertTrue(self.sale_order.invoice_status == 'no', 'Sale: SO status after invoicing should be "nothing to invoice"')
self.assertTrue(len(self.sale_order.invoice_ids) == 1, 'Sale: invoice is missing')
self.sale_order.order_line._compute_product_updatable()
self.assertFalse(self.sale_order.order_line[0].product_updatable)
# deliver lines except 'time and material' then invoice again
for line in self.sale_order.order_line:
line.qty_delivered = 2 if line.product_id.expense_policy == 'no' else 0
self.assertTrue(self.sale_order.invoice_status == 'to invoice', 'Sale: SO status after delivery should be "to invoice"')
invoice2 = self.sale_order._create_invoices()
self.assertEqual(len(invoice2.invoice_line_ids), 2, 'Sale: second invoice is missing lines')
self.assertEqual(invoice2.amount_total, 500.0, 'Sale: second invoice total amount is wrong')
self.assertTrue(self.sale_order.invoice_status == 'invoiced', 'Sale: SO status after invoicing everything should be "invoiced"')
self.assertTrue(len(self.sale_order.invoice_ids) == 2, 'Sale: invoice is missing')
# go over the sold quantity
self.sol_serv_order.write({'qty_delivered': 10})
self.assertTrue(self.sale_order.invoice_status == 'upselling', 'Sale: SO status after increasing delivered qty higher than ordered qty should be "upselling"')
# upsell and invoice
self.sol_serv_order.write({'product_uom_qty': 10})
# There is a bug with `new` and `_origin`
# If you create a first new from a record, then change a value on the origin record, than create another new,
# this other new wont have the updated value of the origin record, but the one from the previous new
# Here the problem lies in the use of `new` in `move = self_ctx.new(new_vals)`,
# and the fact this method is called multiple times in the same transaction test case.
# Here, we update `qty_delivered` on the origin record, but the `new` records which are in cache with this order line
# as origin are not updated, nor the fields that depends on it.
self.env.flush_all()
self.env.invalidate_all()
invoice3 = self.sale_order._create_invoices()
self.assertEqual(len(invoice3.invoice_line_ids), 1, 'Sale: third invoice is missing lines')
self.assertEqual(invoice3.amount_total, 720.0, 'Sale: second invoice total amount is wrong')
self.assertTrue(self.sale_order.invoice_status == 'invoiced', 'Sale: SO status after invoicing everything (including the upsel) should be "invoiced"')
def test_so_create_multicompany(self):
"""Check that only taxes of the right company are applied on the lines."""
# Preparing test Data
product_shared = self.env['product.template'].create({
'name': 'shared product',
'invoice_policy': 'order',
'taxes_id': [(6, False, (self.company_data['default_tax_sale'] + self.company_data_2['default_tax_sale']).ids)],
'property_account_income_id': self.company_data['default_account_revenue'].id,
})
so_1 = self.env['sale.order'].with_user(self.company_data['default_user_salesman']).create({
'partner_id': self.env['res.partner'].create({'name': 'A partner'}).id,
'company_id': self.company_data['company'].id,
})
so_1.write({
'order_line': [Command.create({'product_id': product_shared.product_variant_id.id})],
})
self.assertEqual(so_1.order_line.product_uom_qty, 1)
self.assertEqual(so_1.order_line.tax_id, self.company_data['default_tax_sale'],
'Only taxes from the right company are put by default')
so_1.action_confirm()
# i'm not interested in groups/acls, but in the multi-company flow only
# the sudo is there for that and does not impact the invoice that gets created
# the goal here is to invoice in company 1 (because the order is in company 1) while being
# 'mainly' in company 2 (through the context), the invoice should be in company 1
inv = so_1.sudo().with_context(
allowed_company_ids=(self.company_data['company'] + self.company_data_2['company']).ids
)._create_invoices()
self.assertEqual(
inv.company_id,
self.company_data['company'],
'invoices should be created in the company of the SO, not the main company of the context')
def test_partial_invoicing_interaction_with_invoicing_switch_threshold(self):
""" Let's say you partially invoice a SO, let's call the resuling invoice 'A'. Now if you change the
'Invoicing Switch Threshold' such that the invoice date of 'A' is before the new threshold,
the SO should still take invoice 'A' into account.
"""
if not self.env['ir.module.module'].search([('name', '=', 'account_accountant'), ('state', '=', 'installed')]):
self.skipTest("This test requires the installation of the account_account module")
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'order_line': [
Command.create({
'product_id': self.company_data['product_delivery_no'].id,
'product_uom_qty': 20,
'price_unit': 30,
}),
],
})
line = sale_order.order_line[0]
sale_order.action_confirm()
line.qty_delivered = 10
invoice = sale_order._create_invoices()
invoice.action_post()
self.assertEqual(line.qty_invoiced, 10)
self.env['res.config.settings'].create({
'invoicing_switch_threshold': fields.Date.add(invoice.invoice_date, days=30),
}).execute()
invoice.invalidate_model(fnames=['payment_state'])
self.assertEqual(line.qty_invoiced, 10)
line.qty_delivered = 15
self.assertEqual(line.qty_invoiced, 10)
self.assertEqual(line.untaxed_amount_invoiced, 300)
def test_salesperson_in_invoice_followers(self):
"""
Test if the salesperson is in the followers list of invoice created from SO
"""
# create a salesperson
salesperson = self.env['res.users'].create({
'name': 'Salesperson',
'login': 'salesperson',
'email': 'test@test.com',
'groups_id': [(6, 0, [self.env.ref('sales_team.group_sale_salesman').id])]
})
# create a SO and generate invoice from it
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'user_id': salesperson.id,
'order_line': [(0, 0, {
'product_id': self.company_data['product_order_no'].id,
'product_uom_qty': 1,
})]
})
sale_order.action_confirm()
invoice = sale_order._create_invoices(final=True)
# check if the salesperson is in the followers list of invoice created from SO
self.assertIn(salesperson.partner_id, invoice.message_partner_ids, 'Salesperson not in the followers list of '
'invoice created from SO')