diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 253f36983f..7f88a85db5 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -715,7 +715,7 @@ class AccountBankStatementLine(models.Model): **counterpart_vals, 'name': counterpart_vals.get('name', move_line.name if move_line else ''), 'move_id': self.move_id.id, - 'partner_id': self.partner_id.id or (move_line.partner_id.id if move_line else False), + 'partner_id': self.partner_id.id or counterpart_vals.get('partner_id', move_line.partner_id.id if move_line else False), 'currency_id': currency_id, 'account_id': counterpart_vals.get('account_id', move_line.account_id.id if move_line else False), 'debit': balance if balance > 0.0 else 0.0, diff --git a/addons/auth_ldap/models/res_company_ldap.py b/addons/auth_ldap/models/res_company_ldap.py index 3d831cb5e4..ad05c494ad 100644 --- a/addons/auth_ldap/models/res_company_ldap.py +++ b/addons/auth_ldap/models/res_company_ldap.py @@ -7,6 +7,7 @@ from ldap.filter import filter_format from flectra import _, api, fields, models, tools from flectra.exceptions import AccessDenied +from flectra.tools.misc import str2bool from flectra.tools.pycompat import to_text _logger = logging.getLogger(__name__) @@ -74,6 +75,9 @@ class CompanyLDAP(models.Model): uri = 'ldap://%s:%d' % (conf['ldap_server'], conf['ldap_server_port']) connection = ldap.initialize(uri) + ldap_chase_ref_disabled = self.env['ir.config_parameter'].sudo().get_param('auth_ldap.disable_chase_ref') + if str2bool(ldap_chase_ref_disabled): + connection.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF) if conf['ldap_tls']: connection.start_tls_s() return connection diff --git a/addons/delivery/models/delivery_carrier.py b/addons/delivery/models/delivery_carrier.py index 884e8f8e62..5ff3e16b34 100644 --- a/addons/delivery/models/delivery_carrier.py +++ b/addons/delivery/models/delivery_carrier.py @@ -265,7 +265,7 @@ class DeliveryCarrier(models.Model): 'price': 0.0, 'error_message': _('Error: this delivery method is not available for this address.'), 'warning_message': False} - price = self.fixed_price + price = order.pricelist_id.get_product_price(self.product_id, 1.0, order.partner_id) company = self.company_id or order.company_id or self.env.company if company.currency_id and company.currency_id != order.currency_id: price = company.currency_id._convert(price, order.currency_id, company, fields.Date.today()) diff --git a/addons/delivery/tests/test_delivery_cost.py b/addons/delivery/tests/test_delivery_cost.py index 3eea23d193..a2bad45e09 100644 --- a/addons/delivery/tests/test_delivery_cost.py +++ b/addons/delivery/tests/test_delivery_cost.py @@ -155,3 +155,44 @@ class TestDeliveryCost(common.TransactionCase): self.default_delivery_policy = self.SaleConfigSetting.create({}) self.default_delivery_policy.execute() + + def test_01_delivery_cost_from_pricelist(self): + """ This test aims to validate the use of a pricelist to compute the delivery cost in the case the associated + product of the shipping method is defined in the pricelist """ + + # Create pricelist with a custom price for the standard shipping method + my_pricelist = self.env['product.pricelist'].create({ + 'name': 'shipping_cost_change', + 'item_ids': [(0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 5, + 'applied_on': '0_product_variant', + 'product_id': self.normal_delivery.product_id.id, + })], + }) + + # Create sales order with Normal Delivery Charges + sale_pricelist_based_delivery_charges = self.SaleOrder.create({ + 'partner_id': self.partner_18.id, + 'pricelist_id': my_pricelist.id, + 'order_line': [(0, 0, { + 'name': 'PC Assamble + 2GB RAM', + 'product_id': self.product_4.id, + 'product_uom_qty': 1, + 'product_uom': self.product_uom_unit.id, + 'price_unit': 750.00, + })], + }) + + # Add of delivery cost in Sales order + delivery_wizard = Form(self.env['choose.delivery.carrier'].with_context({ + 'default_order_id': sale_pricelist_based_delivery_charges.id, + 'default_carrier_id': self.normal_delivery.id + })) + self.assertEqual(delivery_wizard.delivery_price, 5.0, "Delivery cost does not correspond to 5.0 in wizard") + delivery_wizard.save().button_confirm() + + line = self.SaleOrderLine.search([('order_id', '=', sale_pricelist_based_delivery_charges.id), + ('product_id', '=', self.normal_delivery.product_id.id)]) + self.assertEqual(len(line), 1, "Delivery cost hasn't been added to SO") + self.assertEqual(line.price_subtotal, 5.0, "Delivery cost does not correspond to 5.0") diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 76dfe6272d..e5ac347271 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -854,11 +854,10 @@ class HolidaysRequest(models.Model): # Business methods #################################################### - def _create_resource_leave(self): - """ This method will create entry in resource calendar time off object at the time of holidays validated - :returns: created `resource.calendar.leaves` + def _prepare_resource_leave_vals_list(self): + """Hook method for others to inject data """ - vals_list = [{ + return [{ 'name': leave.name, 'date_from': leave.date_from, 'holiday_id': leave.id, @@ -866,7 +865,13 @@ class HolidaysRequest(models.Model): 'resource_id': leave.employee_id.resource_id.id, 'calendar_id': leave.employee_id.resource_calendar_id.id, 'time_type': leave.holiday_status_id.time_type, - } for leave in self] + } for leave in self] + + def _create_resource_leave(self): + """ This method will create entry in resource calendar time off object at the time of holidays validated + :returns: created `resource.calendar.leaves` + """ + vals_list = self._prepare_resource_leave_vals_list() return self.env['resource.calendar.leaves'].sudo().create(vals_list) def _remove_resource_leave(self): diff --git a/addons/l10n_cl/models/account_move.py b/addons/l10n_cl/models/account_move.py index 0349fbfaf2..b3514b0b9d 100644 --- a/addons/l10n_cl/models/account_move.py +++ b/addons/l10n_cl/models/account_move.py @@ -21,7 +21,11 @@ class AccountMove(models.Model): self.journal_id.l10n_latam_use_documents: return super()._get_l10n_latam_documents_domain() if self.journal_id.type == 'sale': - domain = [('country_id.code', '=', "CL"), ('internal_type', '!=', 'invoice_in')] + if self.move_type == 'out_refund': + internal_types_domain = ('internal_type', '=', 'credit_note') + else: + internal_types_domain = ('internal_type', 'not in', ['invoice_in', 'credit_note']) + domain = [('country_id.code', '=', 'CL'), internal_types_domain] if self.company_id.partner_id.l10n_cl_sii_taxpayer_type == '1': domain += [('code', '!=', '71')] # Companies with VAT Affected doesn't have "Boleta de honorarios Electrónica" return domain diff --git a/addons/l10n_de_sale/models/sale.py b/addons/l10n_de_sale/models/sale.py index d57b4b386d..6784962262 100644 --- a/addons/l10n_de_sale/models/sale.py +++ b/addons/l10n_de_sale/models/sale.py @@ -28,6 +28,8 @@ class SaleOrder(models.Model): data.append((_('Customer Reference'), record.client_order_ref)) if record.user_id: data.append((_("Salesperson"), record.user_id.name)) + if 'incoterm' in record._fields and record.incoterm: + data.append((_("Incoterm"), record.incoterm.code)) def _compute_l10n_de_document_title(self): for record in self: diff --git a/addons/mail/static/tests/helpers/mock_models.js b/addons/mail/static/tests/helpers/mock_models.js index dc8f3f0da1..92400e9d16 100644 --- a/addons/mail/static/tests/helpers/mock_models.js +++ b/addons/mail/static/tests/helpers/mock_models.js @@ -242,7 +242,7 @@ class MockModels { email_cc: { type: 'char' }, partner_ids: { string: "Related partners", - type: 'many2one', + type: 'one2many', relation: 'res.partner' }, }, diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index 4df5aad7f3..2b53a0f11f 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -108,6 +108,18 @@ class MrpBom(models.Model): bom_product=bom_line.parent_product_tmpl_id.display_name )) + @api.onchange('bom_line_ids', 'product_qty') + def onchange_bom_structure(self): + if self.type == 'phantom' and self._origin and self.env['stock.move'].search([('bom_line_id', 'in', self._origin.bom_line_ids.ids)], limit=1): + return { + 'warning': { + 'title': _('Warning'), + 'message': _( + 'The product has already been used at least once, editing its structure may lead to undesirable behaviours. ' + 'You should rather archive the product and create a new one with a new bill of materials.'), + } + } + @api.onchange('product_uom_id') def onchange_product_uom_id(self): res = {} diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index fa8356b7fd..2266a908e4 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -12,7 +12,7 @@ from dateutil.relativedelta import relativedelta from itertools import groupby from flectra import api, fields, models, _ -from flectra.exceptions import AccessError, UserError +from flectra.exceptions import AccessError, UserError, ValidationError from flectra.tools import float_compare, float_round, float_is_zero, format_datetime from flectra.tools.misc import format_date @@ -709,6 +709,13 @@ class MrpProduction(models.Model): else: self.workorder_ids = False + @api.constrains('product_id', 'move_raw_ids') + def _check_production_lines(self): + for production in self: + for move in production.move_raw_ids: + if production.product_id == move.product_id: + raise ValidationError(_("The component %s should not be the same as the product to produce.") % production.product_id.display_name) + def write(self, vals): if 'workorder_ids' in self: production_to_replan = self.filtered(lambda p: p.is_planned) diff --git a/addons/phone_validation/__init__.py b/addons/phone_validation/__init__.py index 1865412282..ff32098f68 100644 --- a/addons/phone_validation/__init__.py +++ b/addons/phone_validation/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. +from . import lib from . import tools from . import models from . import wizard diff --git a/addons/phone_validation/lib/__init__.py b/addons/phone_validation/lib/__init__.py new file mode 100644 index 0000000000..dbfc59ad0f --- /dev/null +++ b/addons/phone_validation/lib/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. + +from . import phonenumbers_patch diff --git a/addons/phone_validation/lib/phonemetadata.py b/addons/phone_validation/lib/phonemetadata.py new file mode 100644 index 0000000000..b82d9711c7 --- /dev/null +++ b/addons/phone_validation/lib/phonemetadata.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. + +try: + # import for usage in phonenumbers_patch/region_*.py files + from phonenumbers.phonemetadata import NumberFormat, PhoneNumberDesc, PhoneMetadata # pylint: disable=unused-import +except ImportError: + pass diff --git a/addons/phone_validation/lib/phonenumbers_patch/__init__.py b/addons/phone_validation/lib/phonenumbers_patch/__init__.py new file mode 100644 index 0000000000..b0fbdd40a7 --- /dev/null +++ b/addons/phone_validation/lib/phonenumbers_patch/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2009 The Libphonenumber Authors + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# https://github.com/google/libphonenumber + +from flectra.tools.parse_version import parse_version + +try: + import phonenumbers + # MONKEY PATCHING phonemetadata of Ivory Coast if phonenumbers is too old + if parse_version('7.6.1') <= parse_version(phonenumbers.__version__) < parse_version('8.12.32'): + def _local_load_region(code): + __import__("region_%s" % code, globals(), locals(), + fromlist=["PHONE_METADATA_%s" % code], level=1) + # loading updated region_CI.py from current directory + # https://github.com/daviddrysdale/python-phonenumbers/blob/v8.12.32/python/phonenumbers/data/region_CI.py + phonenumbers.phonemetadata.PhoneMetadata.register_region_loader('CI', _local_load_region) +except ImportError: + pass diff --git a/addons/phone_validation/lib/phonenumbers_patch/region_CI.py b/addons/phone_validation/lib/phonenumbers_patch/region_CI.py new file mode 100644 index 0000000000..4dcedc4841 --- /dev/null +++ b/addons/phone_validation/lib/phonenumbers_patch/region_CI.py @@ -0,0 +1,9 @@ +"""Auto-generated file, do not edit by hand. CI metadata""" +from ..phonemetadata import NumberFormat, PhoneNumberDesc, PhoneMetadata + +PHONE_METADATA_CI = PhoneMetadata(id='CI', country_code=225, international_prefix='00', + general_desc=PhoneNumberDesc(national_number_pattern='[02]\\d{9}', possible_length=(10,)), + fixed_line=PhoneNumberDesc(national_number_pattern='2(?:[15]\\d{3}|7(?:2(?:0[23]|1[2357]|[23][45]|4[3-5])|3(?:06|1[69]|[2-6]7)))\\d{5}', example_number='2123456789', possible_length=(10,)), + mobile=PhoneNumberDesc(national_number_pattern='0704[0-7]\\d{5}|0(?:[15]\\d\\d|7(?:0[0-37-9]|[4-9][7-9]))\\d{6}', example_number='0123456789', possible_length=(10,)), + number_format=[NumberFormat(pattern='(\\d{2})(\\d{2})(\\d)(\\d{5})', format='\\1 \\2 \\3 \\4', leading_digits_pattern=['2']), + NumberFormat(pattern='(\\d{2})(\\d{2})(\\d{2})(\\d{4})', format='\\1 \\2 \\3 \\4', leading_digits_pattern=['0'])]) diff --git a/addons/phone_validation/tests/__init__.py b/addons/phone_validation/tests/__init__.py new file mode 100644 index 0000000000..1a438c4fcf --- /dev/null +++ b/addons/phone_validation/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. + +from . import test_phonenumbers_patch diff --git a/addons/phone_validation/tests/test_phonenumbers_patch.py b/addons/phone_validation/tests/test_phonenumbers_patch.py new file mode 100644 index 0000000000..307e79554c --- /dev/null +++ b/addons/phone_validation/tests/test_phonenumbers_patch.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. + +try: + import phonenumbers +except ImportError: + phonenumbers = None + +from flectra.tests.common import BaseCase +from flectra.tools.parse_version import parse_version +from flectra.addons.phone_validation.lib import phonenumbers_patch + +class TestPhonenumbersPatch(BaseCase): + def test_region_CI_monkey_patch(self): + """Test if the patch is apply on the good version of the lib + And test some phonenumbers""" + if not phonenumbers: + self.skipTest('Cannot test without phonenumbers module installed.') + # MONKEY PATCHING phonemetadata of Ivory Coast if phonenumbers is too old + if parse_version('7.6.1') <= parse_version(phonenumbers.__version__) < parse_version('8.12.32'): + # check that _local_load_region is set to `flectra.addons.phone_validation.lib.phonenumbers_patch._local_load_region` + # check that you can load a new ivory coast phone number without error + parsed_phonenumber_1 = phonenumbers.parse("20 25/35-51 ", region="CI", keep_raw_input=True) + self.assertEqual(parsed_phonenumber_1.national_number, 20253551, "The national part of the phonenumber should be 22522586") + self.assertEqual(parsed_phonenumber_1.country_code, 225, "The country code of Ivory Coast is 225") + + parsed_phonenumber_2 = phonenumbers.parse("+225 22 52 25 86 ", region="CI", keep_raw_input=True) + self.assertEqual(parsed_phonenumber_2.national_number, 22522586, "The national part of the phonenumber should be 22522586") + self.assertEqual(parsed_phonenumber_2.country_code, 225, "The country code of Ivory Coast is 225") + else: + self.assertFalse(hasattr(phonenumbers_patch, '_local_load_region'), + "The code should not be monkey patched with phonenumbers > 8.12.32.") diff --git a/addons/purchase/report/purchase_quotation_templates.xml b/addons/purchase/report/purchase_quotation_templates.xml index f4af0a1c73..8fb19763d4 100644 --- a/addons/purchase/report/purchase_quotation_templates.xml +++ b/addons/purchase/report/purchase_quotation_templates.xml @@ -21,7 +21,7 @@
| Description | Expected Date | diff --git a/addons/purchase_mrp/models/stock_move.py b/addons/purchase_mrp/models/stock_move.py index 845091be2d..ac2ad85af3 100644 --- a/addons/purchase_mrp/models/stock_move.py +++ b/addons/purchase_mrp/models/stock_move.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. -from flectra import models +from flectra import _, models +from flectra.tools.float_utils import float_is_zero +from flectra.exceptions import UserError class StockMove(models.Model): @@ -24,4 +26,6 @@ class StockMove(models.Model): } valuation_total_qty = self._compute_kit_quantities(related_aml.product_id, order_qty, kit_bom, filters) valuation_total_qty = kit_bom.product_uom_id._compute_quantity(valuation_total_qty, related_aml.product_id.uom_id) + if float_is_zero(valuation_total_qty, precision_rounding=related_aml.product_uom_id.rounding or related_aml.product_id.uom_id.rounding): + raise UserError(_('Flectra is not able to generate the anglo saxon entries. The total valuation of %s is zero.') % related_aml.product_id.display_name) return valuation_price_unit_total, valuation_total_qty diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 87c69c3a6e..1842e1dda1 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -338,7 +338,7 @@ class SaleOrder(models.Model): for order in self: total = 0.0 for line in order.order_line: - total += line.price_subtotal + line.price_unit * ((line.discount or 0.0) / 100.0) * line.product_uom_qty # why is there a discount in a field named amount_undiscounted ?? + total += (line.price_subtotal * 100)/(100-line.discount) if line.discount != 100 else (line.price_unit * line.product_uom_qty) order.amount_undiscounted = total @api.depends('state') @@ -477,24 +477,10 @@ class SaleOrder(models.Model): def update_prices(self): self.ensure_one() - lines_to_update = [] for line in self._get_update_prices_lines(): - product = line.product_id.with_context( - partner=self.partner_id, - quantity=line.product_uom_qty, - date=self.date_order, - pricelist=self.pricelist_id.id, - uom=line.product_uom.id - ) - price_unit = self.env['account.tax']._fix_tax_included_price_company( - line._get_display_price(product), line.product_id.taxes_id, line.tax_id, line.company_id) - if self.pricelist_id.discount_policy == 'without_discount' and price_unit: - price_discount_unrounded = self.pricelist_id.get_product_price(product, line.product_uom_qty, self.partner_id, self.date_order, line.product_uom.id) - discount = max(0, (price_unit - price_discount_unrounded) * 100 / price_unit) - else: - discount = 0 - lines_to_update.append((1, line.id, {'price_unit': price_unit, 'discount': discount})) - self.update({'order_line': lines_to_update}) + line.product_uom_change() + line.discount = 0 # Force 0 as discount for the cases when _onchange_discount directly returns + line._onchange_discount() self.show_update_pricelist = False self.message_post(body=_("Product prices have been recomputed according to pricelist %s ", self.pricelist_id.display_name)) diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index 1cb0d45ce2..9572a9c63d 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -583,6 +583,59 @@ class TestSaleOrder(TestSaleCommon): 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""" + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'order_line': [(0, 0, { + 'product_id': self.product_a.id, + 'product_uom_qty': 1, + 'price_unit': 100.0, + 'discount': 1.00, + })] + }) + sale_order.action_confirm() + line = sale_order.order_line + + # test discount and qty 1 + self.assertEqual(sale_order.amount_undiscounted, 100.0) + self.assertEqual(line.price_subtotal, 99.0) + + # more quantity 1 -> 3 + sale_form = Form(sale_order) + with sale_form.order_line.edit(0) as line_form: + line_form.product_uom_qty = 3.0 + line_form.price_unit = 100.0 + sale_order = sale_form.save() + + self.assertEqual(sale_order.amount_undiscounted, 300.0) + self.assertEqual(line.price_subtotal, 297.0) + + # undiscounted + with sale_form.order_line.edit(0) as line_form: + line_form.discount = 0.0 + sale_order = sale_form.save() + self.assertEqual(line.price_subtotal, 300.0) + self.assertEqual(sale_order.amount_undiscounted, 300.0) + + # Same with an included-in-price tax + sale_order = sale_order.copy() + line = sale_order.order_line + line.tax_id = [(0, 0, { + 'name': 'Super Tax', + 'amount_type': 'percent', + 'amount': 10.0, + 'price_include': True, + })] + line.discount = 50.0 + sale_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(sale_order.amount_undiscounted, 272.72) + self.assertEqual(line.price_subtotal, 136.36) + def test_free_product_and_price_include_fixed_tax(self): """ Check that fixed tax include are correctly computed while the price_unit is 0 """ diff --git a/addons/sale/tests/test_sale_pricelist.py b/addons/sale/tests/test_sale_pricelist.py index dc19f55dd6..4f86307686 100644 --- a/addons/sale/tests/test_sale_pricelist.py +++ b/addons/sale/tests/test_sale_pricelist.py @@ -71,6 +71,18 @@ class TestSaleOrder(TestSaleCommon): 'percent_price': 20, }) + # Create a pricelist without discount policy: percentage on all products + cls.pricelist_discount_excl_global = cls.env['product.pricelist'].create({ + 'name': 'Pricelist C', + 'discount_policy': 'without_discount', + 'company_id': cls.env.company.id, + 'item_ids': [(0, 0, { + 'applied_on': '3_global', + 'compute_price': 'percentage', + 'percent_price': 54, + })], + }) + # create a generic Sale Order with all classical products and empty pricelist cls.sale_order = SaleOrder.create({ 'partner_id': cls.partner_a.id, @@ -164,18 +176,7 @@ class TestSaleOrder(TestSaleCommon): def test_sale_change_of_pricelists_excluded_value_discount(self): """ Test SO with the pricelist 'discount displayed' and check displayed percentage value after multiple changes of pricelist """ - - # Create a pricelist without discount policy: percentage on all products - pricelist_discount_excl_global = self.env['product.pricelist'].create({ - 'name': 'Pricelist C', - 'discount_policy': 'without_discount', - 'company_id': self.env.company.id, - 'item_ids': [(0, 0, { - 'applied_on': '3_global', - 'compute_price': 'percentage', - 'percent_price': 54, - })], - }) + self.env.user.write({'groups_id': [(4, self.env.ref('product.group_discount_per_so_line').id)]}) # Create a product with a very low price amazing_product = self.env['product.product'].create({ @@ -200,7 +201,7 @@ class TestSaleOrder(TestSaleCommon): }) # Change the pricelist - sale_order.write({'pricelist_id': pricelist_discount_excl_global.id}) + sale_order.write({'pricelist_id': self.pricelist_discount_excl_global.id}) # Update Prices sale_order.update_prices() @@ -222,3 +223,79 @@ class TestSaleOrder(TestSaleCommon): sale_order.order_line.tax_id, "Wrong tax applied for specified product & pricelist" ) + + def test_sale_change_of_pricelists_excluded_value_discount_on_tax_included_price_mapped_to_tax_excluded_price(self): + self.env.user.write({'groups_id': [(4, self.env.ref('product.group_discount_per_so_line').id)]}) + + # setting up the taxes: + tax_a = self.tax_sale_a.copy() + tax_b = self.tax_sale_a.copy() + tax_a.price_include = True + tax_b.amount = 6 + + # setting up fiscal position: + fiscal_pos = self.fiscal_pos_a.copy() + fiscal_pos.auto_apply = True + country = self.env["res.country"].search([('name', '=', 'Belgium')], limit=1) + fiscal_pos.country_id = country + fiscal_pos.tax_ids = [ + (0, None, + { + 'tax_src_id': tax_a.id, + 'tax_dest_id': tax_b.id + }) + ] + + # setting up partner: + self.partner_a.country_id = country + + # creating product: + + my_product = self.env['product.product'].create({ + 'name': 'my Product', + 'lst_price': 115, + 'taxes_id': [tax_a.id] + }) + + # creating SO + + sale_order = 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, + 'order_line': [(0, 0, { + 'name': my_product.name, + 'product_id': my_product.id, + 'product_uom_qty': 1, + 'product_uom': my_product.uom_id.id, + })], + }) + + # Apply fiscal position + + sale_order.fiscal_position_id = fiscal_pos.id + # Change the pricelist + sale_order.write({'pricelist_id': self.pricelist_discount_excl_global.id}) + # Update Prices + sale_order.update_prices() + + + # Check that the discount displayed is the correct one + self.assertEqual( + sale_order.order_line.discount, 54, + "Wrong discount computed for specified product & pricelist" + ) + # Additional to check for overall consistency + self.assertEqual( + sale_order.order_line.price_unit, 100, + "Wrong unit price computed for specified product & pricelist" + ) + self.assertEqual( + sale_order.order_line.price_subtotal, 46, + "Wrong subtotal price computed for specified product & pricelist" + ) + self.assertEqual( + sale_order.order_line.tax_id.id, tax_b.id, + "Wrong tax applied for specified product & pricelist" + ) diff --git a/addons/sale_management/models/sale_order.py b/addons/sale_management/models/sale_order.py index f6630320e6..577ce01de3 100644 --- a/addons/sale_management/models/sale_order.py +++ b/addons/sale_management/models/sale_order.py @@ -86,8 +86,7 @@ class SaleOrder(models.Model): def update_prices(self): self.ensure_one() res = super().update_prices() - for line in self.sale_order_option_ids: - line.price_unit = self.pricelist_id.get_product_price(line.product_id, line.quantity, self.partner_id, uom_id=line.uom_id.id) + self.sale_order_option_ids._update_price_and_discount() return res @api.onchange('sale_order_template_id') @@ -150,9 +149,12 @@ class SaleOrder(models.Model): def action_confirm(self): res = super(SaleOrder, self).action_confirm() + if self.env.su: + self = self.with_user(SUPERUSER_ID) + for order in self: if order.sale_order_template_id and order.sale_order_template_id.mail_template_id: - order.sale_order_template_id.mail_template_id.with_user(SUPERUSER_ID).send_mail(order.id) + order.sale_order_template_id.mail_template_id.send_mail(order.id) return res def get_access_action(self, access_uid=None): @@ -208,6 +210,26 @@ class SaleOrderOption(models.Model): quantity = fields.Float('Quantity', required=True, digits='Product Unit of Measure', default=1) sequence = fields.Integer('Sequence', help="Gives the sequence order when displaying a list of optional products.") + def _update_price_and_discount(self): + for option in self: + if not option.product_id: + continue + # To compute the discount a so line is created in cache + values = option._get_values_to_add_to_order() + new_sol = option.env['sale.order.line'].new(values) + new_sol._onchange_discount() + option.discount = new_sol.discount + if option.order_id.pricelist_id and option.order_id.partner_id: + product = option.product_id.with_context( + partner=option.order_id.partner_id, + quantity=option.quantity, + date=option.order_id.date_order, + pricelist=option.order_id.pricelist_id.id, + uom=option.uom_id.id, + fiscal_position=option.env.context.get('fiscal_position') + ) + option.price_unit = new_sol._get_display_price(product) + @api.depends('line_id', 'order_id.order_line', 'product_id') def _compute_is_present(self): # NOTE: this field cannot be stored as the line_id is usually removed @@ -226,22 +248,9 @@ class SaleOrderOption(models.Model): return product = self.product_id.with_context( lang=self.order_id.partner_id.lang, - partner=self.order_id.partner_id, - quantity=self.quantity, - date=self.order_id.date_order, - pricelist=self.order_id.pricelist_id.id, - uom=self.uom_id.id, - fiscal_position=self.env.context.get('fiscal_position') ) self.name = product.get_product_multiline_description_sale() - self.uom_id = self.uom_id or product.uom_id - # To compute the discount a so line is created in cache - values = self._get_values_to_add_to_order() - new_sol = self.env['sale.order.line'].new(values) - new_sol._onchange_discount() - self.discount = new_sol.discount - if self.order_id.pricelist_id and self.order_id.partner_id: - self.price_unit = new_sol._get_display_price(product) + self._update_price_and_discount() def button_add_to_order(self): self.add_option_to_order() diff --git a/addons/sale_management/tests/test_sale_order.py b/addons/sale_management/tests/test_sale_order.py index 1634ee5bbd..2733da5c4c 100644 --- a/addons/sale_management/tests/test_sale_order.py +++ b/addons/sale_management/tests/test_sale_order.py @@ -283,3 +283,61 @@ class TestSaleOrder(TestSaleCommon): "If a pricelist is set without discount included, the discount " "shall be computed according to the price unit and the subtotal." "price") + + def test_04_update_pricelist_option_line(self): + """ + This test checks that option line's values are correctly + updated after a pricelist update + """ + + # Necessary for _onchange_discount() check + self.env.user.write({ + 'groups_id': [(4, self.env.ref('product.group_discount_per_so_line').id)], + }) + + self.sale_order.write({ + 'sale_order_template_id': self.quotation_template_no_discount.id + }) + self.sale_order.onchange_sale_order_template_id() + + self.assertEqual( + self.sale_order.sale_order_option_ids[0].price_unit, + self.pub_option_price, + "If no pricelist is set, the unit price shall be the option's product price.") + + self.assertEqual( + self.sale_order.sale_order_option_ids[0].discount, 0, + "If no pricelist is set, the discount should be 0.") + + self.sale_order.write({ + 'pricelist_id': self.discount_included_price_list.id, + }) + self.sale_order.update_prices() + + self.assertEqual( + self.sale_order.sale_order_option_ids[0].price_unit, + self.pl_option_price, + "If a pricelist is set with discount included," + " the unit price shall be the option's product discounted price.") + + self.assertEqual( + self.sale_order.sale_order_option_ids[0].discount, 0, + "If a pricelist is set with discount included," + " the discount should be 0.") + + self.sale_order.write({ + 'pricelist_id': self.discount_excluded_price_list.id, + }) + self.sale_order.update_prices() + + self.assertEqual( + self.sale_order.sale_order_option_ids[0].price_unit, + self.pub_option_price, + "If a pricelist is set without discount included," + " the unit price shall be the option's product sale price.") + + self.assertEqual( + self.sale_order.sale_order_option_ids[0].discount, + self.pl_option_discount, + "If a pricelist is set without discount included," + " the discount should be correctly computed.") diff --git a/addons/sale_project/models/sale_order.py b/addons/sale_project/models/sale_order.py index e631c422e0..74656ccbba 100644 --- a/addons/sale_project/models/sale_order.py +++ b/addons/sale_project/models/sale_order.py @@ -122,12 +122,12 @@ class SaleOrderLine(models.Model): index=True, copy=False, help="Task generated by the sales order item") is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.") - @api.depends('product_id') + @api.depends('product_id.type') def _compute_is_service(self): for so_line in self: so_line.is_service = so_line.product_id.type == 'service' - @api.depends('product_id') + @api.depends('product_id.type') def _compute_product_updatable(self): for line in self: if line.product_id.type == 'service' and line.state == 'sale': diff --git a/addons/sale_project/tests/test_sale_project.py b/addons/sale_project/tests/test_sale_project.py index 6053bb33d0..ebcfbf91bc 100644 --- a/addons/sale_project/tests/test_sale_project.py +++ b/addons/sale_project/tests/test_sale_project.py @@ -139,3 +139,24 @@ class TestSaleProject(SavepointCase): # service_tracking 'project_only' self.assertFalse(so_line_order_only_project.task_id, "Task should not be created") self.assertTrue(so_line_order_only_project.project_id, "Sales order line should be linked to newly created project") + + def test_sol_product_type_update(self): + partner = self.env['res.partner'].create({'name': "Mur en brique"}) + sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({ + 'partner_id': partner.id, + 'partner_invoice_id': partner.id, + 'partner_shipping_id': partner.id, + }) + self.product_order_service3.type = 'consu' + sale_order_line = self.env['sale.order.line'].create({ + 'order_id': sale_order.id, + 'name': self.product_order_service3.name, + 'product_id': self.product_order_service3.id, + 'product_uom_qty': 5, + 'product_uom': self.product_order_service3.uom_id.id, + 'price_unit': self.product_order_service3.list_price + }) + self.assertFalse(sale_order_line.is_service, "As the product is consumable, the SOL should not be a service") + + self.product_order_service3.type = 'service' + self.assertTrue(sale_order_line.is_service, "As the product is a service, the SOL should be a service") diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index bea62ee74b..07c9923a28 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -648,19 +648,22 @@ class Picking(models.Model): # As the on_change in one2many list is WIP, we will overwrite the locations on the stock moves here # As it is a create the format will be a list of (0, 0, dict) moves = vals.get('move_lines', []) + vals.get('move_ids_without_package', []) - if moves and vals.get('location_id') and vals.get('location_dest_id'): + if moves and ((vals.get('location_id') and vals.get('location_dest_id')) or vals.get('partner_id')): for move in moves: if len(move) == 3 and move[0] == 0: - move[2]['location_id'] = vals['location_id'] - move[2]['location_dest_id'] = vals['location_dest_id'] - # When creating a new picking, a move can have no `company_id` (create before - # picking type was defined) or a different `company_id` (the picking type was - # changed for an another company picking type after the move was created). - # So, we define the `company_id` in one of these cases. - picking_type = self.env['stock.picking.type'].browse(vals['picking_type_id']) - if 'picking_type_id' not in move[2] or move[2]['picking_type_id'] != picking_type.id: - move[2]['picking_type_id'] = picking_type.id - move[2]['company_id'] = picking_type.company_id.id + if vals.get('location_id') and vals.get('location_dest_id'): + move[2]['location_id'] = vals['location_id'] + move[2]['location_dest_id'] = vals['location_dest_id'] + # When creating a new picking, a move can have no `company_id` (create before + # picking type was defined) or a different `company_id` (the picking type was + # changed for an another company picking type after the move was created). + # So, we define the `company_id` in one of these cases. + picking_type = self.env['stock.picking.type'].browse(vals['picking_type_id']) + if 'picking_type_id' not in move[2] or move[2]['picking_type_id'] != picking_type.id: + move[2]['picking_type_id'] = picking_type.id + move[2]['company_id'] = picking_type.company_id.id + if vals.get('partner_id'): + move[2]['partner_id'] = vals.get('partner_id') # make sure to write `schedule_date` *after* the `stock.move` creation in # order to get a determinist execution of `_set_scheduled_date` scheduled_date = vals.pop('scheduled_date', False) @@ -700,6 +703,8 @@ class Picking(models.Model): after_vals['location_id'] = vals['location_id'] if vals.get('location_dest_id'): after_vals['location_dest_id'] = vals['location_dest_id'] + if 'partner_id' in vals: + after_vals['partner_id'] = vals['partner_id'] if after_vals: self.mapped('move_lines').filtered(lambda move: not move.scrapped).write(after_vals) if vals.get('move_lines'): diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 6e90665185..89ab2ad849 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -626,7 +626,8 @@ The correction could unreserve some operations with problematics products.""", p :param domain: List for the domain, empty by default. :param extend: If True, enables form, graph and pivot views. False by default. """ - self._quant_tasks() + if not self.env['ir.config_parameter'].sudo().get_param('stock.skip_quant_tasks'): + self._quant_tasks() ctx = dict(self.env.context or {}) ctx.pop('group_by', None) action = { diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py index 208ad2a25b..22629ed3f4 100644 --- a/addons/stock/models/stock_rule.py +++ b/addons/stock/models/stock_rule.py @@ -293,6 +293,15 @@ class StockRule(models.Model): if not self.location_id.should_bypass_reservation(): move_dest_ids = values.get('move_dest_ids', False) and [(4, x.id) for x in values['move_dest_ids']] or [] + # when create chained moves for inter-warehouse transfers, set the warehouses as partners + if not partner and move_dest_ids: + move_dest = values['move_dest_ids'] + if location_id == company_id.internal_transit_location_id: + partners = move_dest.location_dest_id.get_warehouse().partner_id + if len(partners) == 1: + partner = partners + move_dest.partner_id = partner + move_values = { 'name': name[:2000], 'company_id': self.company_id.id or self.location_src_id.company_id.id or self.location_id.company_id.id or company_id.id, diff --git a/addons/stock/report/picking_templates.xml b/addons/stock/report/picking_templates.xml index 2bcbf006cb..69ec24e6d6 100644 --- a/addons/stock/report/picking_templates.xml +++ b/addons/stock/report/picking_templates.xml @@ -5,10 +5,16 @@
|---|