From 817ff31d398560fcf185f6d43a43dade9b01c7df Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Mon, 17 Aug 2026 17:28:51 +0200 Subject: [PATCH] [REF] website_sale_aplicoop: price through website_sale instead of privately Eskaera carried its own pricing path: a `website_sale_aplicoop.pricelist_id` setting resolved inside the Eskaera controllers, plus a local reimplementation of the tax and discount maths. The pricelist part meant /shop and Eskaera could quote different prices on the same website, and the maths part had drifted from the original it was copied from. Pricing is now the standard one. The setting is gone (a migration drops the parameter, which would otherwise linger looking like live configuration) and `_resolve_pricelist` is `request.website.pricelist_id`. Scoping a pricelist to a website is already core's job through `product.pricelist.website_id`, so running the plain shop on one website and the co-op on another needs no code here. Taxes are applied by `product.template._apply_taxes_to_price`, which fixes a real defect: it calls `_get_tax_included_unit_price_from_price` first, and this module did not. With a fiscal position remapping a tax-included tax, a product at 121 (100 + 21%) was displayed at 121 instead of 110, because the price was handed to the mapped tax as if it were already that tax's gross amount. The helper is a no-op without a remapping, so ordinary pricing is untouched. It also means the website's `show_line_subtotals_tax_selection` is respected rather than overridden with a hardcoded tax-included display. Listing a page now costs one `_compute_price_rule` call instead of one per product, which matters on the lazy-loading path. Two smaller things found on the way. `_get_product_price_rule` was being passed `target_currency=`, which is not one of its arguments: it fell into **kwargs and was ignored, so the conversion never happened. And `_compute_price_info` resolved `product.product_variant_ids[0]`, but it receives variants, so a multi-variant template was priced from its first variant rather than the one asked for. The delivery display price loses its hardcoded 5.74 fallback and reuses the same helpers as everything else. Kept local, because none of it is pricing: the /Kg and /L suffixes, the 0.1 quantity step for bulk goods, the base unit price and the supplier name. Co-Authored-By: Claude Opus 5 --- website_sale_aplicoop/__manifest__.py | 2 +- .../controllers/website_sale.py | 15 +- .../controllers/website_sale_pricing.py | 321 +++++++++--------- .../migrations/18.0.1.15.0/post-migrate.py | 39 +++ .../models/res_config_settings.py | 6 - website_sale_aplicoop/tests/__init__.py | 1 + .../tests/test_phase3_confirm_eskaera.py | 8 +- .../tests/test_pricing_delegation.py | 280 +++++++++++++++ .../tests/test_pricing_with_pricelist.py | 9 +- .../views/res_config_settings_views.xml | 17 - 10 files changed, 496 insertions(+), 202 deletions(-) create mode 100644 website_sale_aplicoop/migrations/18.0.1.15.0/post-migrate.py create mode 100644 website_sale_aplicoop/tests/test_pricing_delegation.py diff --git a/website_sale_aplicoop/__manifest__.py b/website_sale_aplicoop/__manifest__.py index ddf73ea..97c2a0e 100644 --- a/website_sale_aplicoop/__manifest__.py +++ b/website_sale_aplicoop/__manifest__.py @@ -3,7 +3,7 @@ { # noqa: B018 "name": "Website Sale - Aplicoop", - "version": "18.0.1.14.0", + "version": "18.0.1.15.0", "category": "Website/Sale", "summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders", "author": "Odoo Community Association (OCA), Criptomart", diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index 4670f14..28404b1 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -25,6 +25,7 @@ from .exceptions import GroupOrderUnavailable _logger = logging.getLogger(__name__) + class AplicoopWebsiteSale(WebsiteSale): """Controlador personalizado para website_sale de Aplicoop. @@ -54,7 +55,12 @@ class AplicoopWebsiteSale(WebsiteSale): # ========== PHASE 1: HELPER METHODS FOR VALIDATION AND CONFIGURATION ========== def _resolve_pricelist(self): - return _pricing._resolve_pricelist(self, request) + """The pricelist to quote in: whatever website_sale resolved. + + Eskaera used to carry its own pricelist setting; it now prices with the + standard one, so the shop, the cart and these pages cannot drift apart. + """ + return request.website.pricelist_id def _prepare_product_display_info(self, product, product_price_info): return _pricing._prepare_product_display_info( @@ -64,13 +70,12 @@ class AplicoopWebsiteSale(WebsiteSale): request, ) - def _get_pricing_info(self, product, pricelist, quantity=1.0, partner=None): + def _get_pricing_info(self, product, pricelist, quantity=1.0): return _pricing._get_pricing_info( self, product, pricelist, quantity=quantity, - partner=partner, request_obj=request, ) @@ -176,7 +181,6 @@ class AplicoopWebsiteSale(WebsiteSale): """ sale_order_lines = [] pricelist = pricelist or self._resolve_pricelist() - partner = request.env.user.partner_id for item in items: try: @@ -198,7 +202,6 @@ class AplicoopWebsiteSale(WebsiteSale): product, pricelist, quantity=quantity, - partner=partner, ) line_data = { @@ -1657,13 +1660,11 @@ class AplicoopWebsiteSale(WebsiteSale): # Extract items from the draft order items = [] pricelist = self._resolve_pricelist() - partner = current_user.partner_id for line in draft_order.order_line: pricing = self._get_pricing_info( line.product_id, pricelist, quantity=line.product_uom_qty, - partner=partner, ) items.append( { diff --git a/website_sale_aplicoop/controllers/website_sale_pricing.py b/website_sale_aplicoop/controllers/website_sale_pricing.py index 9deef53..66a0fec 100644 --- a/website_sale_aplicoop/controllers/website_sale_pricing.py +++ b/website_sale_aplicoop/controllers/website_sale_pricing.py @@ -6,42 +6,6 @@ from odoo.http import request _logger = logging.getLogger(__name__) -def _resolve_pricelist(self, request_obj=None): - try: - req = request_obj or request - env = req.env - website = req.website - except RuntimeError: - env = getattr(self, "env", None) or self.env - website = env["website"].get_current_website() - pricelist = None - try: - param_value = ( - env["ir.config_parameter"] - .sudo() - .get_param("website_sale_aplicoop.pricelist_id") - ) - if param_value: - pricelist = ( - env["product.pricelist"].browse(int(param_value)).exists() or None - ) - except Exception as e: - _logger.warning("_resolve_pricelist: error reading config param: %s", e) - - if not pricelist: - try: - pricelist = website._get_current_pricelist() - except Exception as e: - _logger.warning( - "_resolve_pricelist: fallback to website pricelist failed: %s", e - ) - - if not pricelist: - pricelist = env["product.pricelist"].sudo().search([], limit=1) - - return pricelist - - def _prepare_product_display_info(self, product, product_price_info, request_obj=None): price_data = product_price_info.get(product.id, {}) price = ( @@ -119,132 +83,182 @@ def _prepare_product_display_info(self, product, product_price_info, request_obj } -def _get_pricing_info( - self, - product, - pricelist, - quantity=1.0, - partner=None, - request_obj=None, -): - req = request_obj or request +def _pricing_context(record, request_obj=None): + """Return (env, website), both under an HTTP request and from the cron.""" try: - env = req.env - website = req.website + req = request_obj or request + return req.env, req.website except RuntimeError: - env = product.env - website = env["website"].get_current_website() + env = record.env + return env, env["website"].get_current_website() - partner = partner or env.user.partner_id - currency = pricelist.currency_id + +def _pricing_company(product, website, env): website_company = ( website.company_id if website and getattr(website, "company_id", False) else False ) - company = website_company or product.company_id or env.company + return website_company or product.company_id or env.company - price, rule_id = pricelist._get_product_price_rule( - product=product, quantity=quantity, target_currency=currency + +def _display_taxes(product, website, company): + """Product taxes, and those same taxes after the website fiscal position.""" + product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company) + if not product_taxes: + return product_taxes, product_taxes + fiscal_position = ( + website.fiscal_position_id.sudo() + if website and getattr(website, "fiscal_position_id", False) + else product.env["account.fiscal.position"].sudo() ) + return product_taxes, fiscal_position.map_tax(product_taxes) + + +def _display_price(product, price, currency, product_taxes, taxes, website): + """Price as the shop displays it, computed by website_sale itself. + + `product.template._apply_taxes_to_price` rebases the price onto the taxes + the fiscal position mapped to (which matters for tax-included taxes) and + then honours the website's `show_line_subtotals_tax_selection`. + """ + return product.product_tmpl_id._apply_taxes_to_price( + price, currency, product_taxes, taxes, product, website=website + ) + + +def _build_pricing_info( + product, price, pricelist_item, currency, quantity, website, company +): + """Assemble the pricing dict from an already resolved pricelist price.""" price_before_discount = price - pricelist_item = env["product.pricelist.item"].sudo().browse(rule_id) if pricelist_item and pricelist_item._show_discount_on_shop(): price_before_discount = pricelist_item._compute_price_before_discount( product=product, quantity=quantity or 1.0, - date=fields.Date.context_today(pricelist), + date=fields.Date.context_today(product), uom=product.uom_id, currency=currency, ) - has_discounted_price = price_before_discount > price - - fiscal_position = ( - website.fiscal_position_id.sudo() - if website and getattr(website, "fiscal_position_id", False) - else env["account.fiscal.position"].sudo() + product_taxes, taxes = _display_taxes(product, website, company) + display_price = _display_price( + product, price, currency, product_taxes, taxes, website + ) + display_list_price = _display_price( + product, price_before_discount, currency, product_taxes, taxes, website ) - product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company) - taxes = fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes - tax_display = "total_included" - - def compute_display(amount): - if not taxes: - return amount - return taxes.compute_all(amount, currency, 1, product, partner)[tax_display] - - display_price = compute_display(price) - display_list_price = compute_display(price_before_discount) return { "price_unit": price, "price": display_price, "list_price": display_list_price, - "has_discounted_price": has_discounted_price, + "has_discounted_price": price_before_discount > price, "discount": display_list_price - display_price, - "tax_included": tax_display == "total_included", + "tax_included": _tax_included(website), + } + + +def _tax_included(website): + """Whether displayed prices carry tax, per the website setting.""" + if not website: + return True + return website.show_line_subtotals_tax_selection != "tax_excluded" + + +def _get_pricing_info( + self, + product, + pricelist, + quantity=1.0, + request_obj=None, +): + """Price one product. + + No partner argument: website_sale bills the tax display to the current + user's partner itself, which is the same partner every caller here passed. + """ + env, website = _pricing_context(product, request_obj) + currency = pricelist.currency_id + + # No currency kwarg: _compute_price_rule already defaults to the + # pricelist's own currency, which is what we want here. + price, rule_id = pricelist._get_product_price_rule(product, quantity) + + return _build_pricing_info( + product, + price, + env["product.pricelist.item"].sudo().browse(rule_id), + currency, + quantity, + website, + _pricing_company(product, website, env), + ) + + +def _pricing_variant(product): + """The variant to price: the record itself when it already is one.""" + if product._name == "product.product": + return product + return product.product_variant_ids[:1] + + +def _fallback_pricing(product, website): + """Plain list price, used when there is no pricelist or pricing failed.""" + price = product.list_price + return { + "price_unit": price, + "price": price, + "list_price": price, + "has_discounted_price": False, + "discount": 0.0, + "tax_included": _tax_included(website), } def _compute_price_info(self, products, pricelist, request_obj=None): + """Price a whole page of products with a single pricelist resolution.""" + if not products: + return {} + + env, website = _pricing_context(products, request_obj) + currency = pricelist.currency_id if pricelist else None + + variants = {product.id: _pricing_variant(product) for product in products} + priceable = env["product.product"].browse() + for variant in variants.values(): + priceable |= variant + + # One pricelist resolution for the whole page rather than one per product. + rules = ( + pricelist._compute_price_rule(priceable, 1.0) if pricelist and priceable else {} + ) + product_price_info = {} - - def _tax_included_default(product_record): - try: - req = request_obj or request - return req.website.show_line_subtotals_tax_selection != "tax_excluded" - except RuntimeError: - website = product_record.env["website"].get_current_website() - if not website: - return True - return website.show_line_subtotals_tax_selection != "tax_excluded" - for product in products: - product_variant = ( - product.product_variant_ids[0] if product.product_variant_ids else False - ) - if product_variant and pricelist: - try: - try: - req = request_obj or request - partner = req.env.user.partner_id - except RuntimeError: - partner = product_variant.env.user.partner_id - - pricing = _get_pricing_info( - self, - product_variant, - pricelist, - quantity=1.0, - partner=partner, - request_obj=request_obj, - ) - product_price_info[product.id] = pricing - except Exception as e: - _logger.warning( - "_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.", - product.name, - product.id, - str(e), - ) - product_price_info[product.id] = { - "price_unit": product.list_price, - "price": product.list_price, - "list_price": product.list_price, - "has_discounted_price": False, - "discount": 0.0, - "tax_included": _tax_included_default(product), - } - else: - product_price_info[product.id] = { - "price_unit": product.list_price, - "price": product.list_price, - "list_price": product.list_price, - "has_discounted_price": False, - "discount": 0.0, - "tax_included": _tax_included_default(product), - } + variant = variants[product.id] + if not variant or variant.id not in rules: + product_price_info[product.id] = _fallback_pricing(product, website) + continue + try: + price, rule_id = rules[variant.id] + product_price_info[product.id] = _build_pricing_info( + variant, + price, + env["product.pricelist.item"].sudo().browse(rule_id), + currency, + 1.0, + website, + _pricing_company(variant, website, env), + ) + except Exception as e: + _logger.warning( + "_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.", + product.name, + product.id, + str(e), + ) + product_price_info[product.id] = _fallback_pricing(product, website) return product_price_info @@ -265,46 +279,27 @@ def _get_delivery_product_display_price( self, delivery_product, pricelist=None, request_obj=None ): if not delivery_product: - return 5.74 + return 0.0 try: base_price = float(delivery_product.list_price or 0.0) - try: - req = request_obj or request - website = req.website - partner = req.env.user.partner_id - company = ( - website.company_id or delivery_product.company_id or req.env.company - ) - except RuntimeError: - env = delivery_product.env - website = env["website"].get_current_website() - partner = env.user.partner_id - company = website.company_id or delivery_product.company_id or env.company - - product_taxes = delivery_product.sudo().taxes_id._filter_taxes_by_company( - company - ) - fiscal_position = ( - website.fiscal_position_id.sudo() - if website and getattr(website, "fiscal_position_id", False) - else delivery_product.env["account.fiscal.position"] - ) - taxes = ( - fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes - ) + env, website = _pricing_context(delivery_product, request_obj) + company = _pricing_company(delivery_product, website, env) + product_taxes, taxes = _display_taxes(delivery_product, website, company) if not taxes: return base_price - currency = website.currency_id - totals = taxes.compute_all( - base_price, - currency=currency, - quantity=1.0, - product=delivery_product, - partner=partner, + return float( + _display_price( + delivery_product, + base_price, + website.currency_id, + product_taxes, + taxes, + website, + ) + or 0.0 ) - return float(totals.get("total_included", base_price) or 0.0) except Exception as e: _logger.warning( "_get_delivery_product_display_price: Error computing delivery display price for product %s (id=%s): %s. Using list_price fallback.", diff --git a/website_sale_aplicoop/migrations/18.0.1.15.0/post-migrate.py b/website_sale_aplicoop/migrations/18.0.1.15.0/post-migrate.py new file mode 100644 index 0000000..12fe7bb --- /dev/null +++ b/website_sale_aplicoop/migrations/18.0.1.15.0/post-migrate.py @@ -0,0 +1,39 @@ +"""Drop the Eskaera-specific pricelist setting. + +Eskaera used to resolve its own pricelist from +`website_sale_aplicoop.pricelist_id`, which let it quote prices the rest of the +shop did not use. Pricing is now the standard website_sale one, so the setting +is gone and its parameter would otherwise linger in the database, looking like +live configuration. +""" + +import logging + +from odoo import SUPERUSER_ID +from odoo import api + +_logger = logging.getLogger(__name__) + +OBSOLETE_PARAM = "website_sale_aplicoop.pricelist_id" + + +def migrate(cr, version): + if not version: + return + + env = api.Environment(cr, SUPERUSER_ID, {}) + param = ( + env["ir.config_parameter"] + .sudo() + .search([("key", "=", OBSOLETE_PARAM)], limit=1) + ) + if not param: + return + + _logger.info( + "Removing obsolete config parameter %s (was pointing at pricelist %s). " + "Eskaera now prices with the website pricelist.", + OBSOLETE_PARAM, + param.value, + ) + param.unlink() diff --git a/website_sale_aplicoop/models/res_config_settings.py b/website_sale_aplicoop/models/res_config_settings.py index 03aaca1..a363a09 100644 --- a/website_sale_aplicoop/models/res_config_settings.py +++ b/website_sale_aplicoop/models/res_config_settings.py @@ -7,12 +7,6 @@ from odoo import models class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" - aplicoop_pricelist_id = fields.Many2one( - "product.pricelist", - config_parameter="website_sale_aplicoop.pricelist_id", - help="Pricelist to use for Aplicoop group orders. If not set, will use website default.", - ) - eskaera_lazy_loading_enabled = fields.Boolean( string="Enable Lazy Loading", config_parameter="website_sale_aplicoop.lazy_loading_enabled", diff --git a/website_sale_aplicoop/tests/__init__.py b/website_sale_aplicoop/tests/__init__.py index 5afc215..8c6abf2 100644 --- a/website_sale_aplicoop/tests/__init__.py +++ b/website_sale_aplicoop/tests/__init__.py @@ -23,3 +23,4 @@ from . import test_product_discovery # noqa: F401 from . import test_date_edge_cases # noqa: F401 from . import test_portal_routes # noqa: F401 from . import test_price_with_taxes_included # noqa: F401 +from . import test_pricing_delegation # noqa: F401 diff --git a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py index 985602d..e3488a6 100644 --- a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py +++ b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py @@ -58,8 +58,14 @@ def _build_request_mock(env, payload=None, website=None): call pricing helpers). """ if website is None: + # `pricelist_id` is what the pricing helpers read now that Eskaera + # prices with the standard website pricelist. Ordered by id on + # purpose: the multilang cases build an env whose language is not + # installed, and the default order is by the translated `name`. website = SimpleNamespace( - _get_current_pricelist=lambda: False, + pricelist_id=env["product.pricelist"] + .sudo() + .search([], limit=1, order="id"), show_line_subtotals_tax_selection="tax_excluded", fiscal_position_id=False, company_id=False, diff --git a/website_sale_aplicoop/tests/test_pricing_delegation.py b/website_sale_aplicoop/tests/test_pricing_delegation.py new file mode 100644 index 0000000..1481511 --- /dev/null +++ b/website_sale_aplicoop/tests/test_pricing_delegation.py @@ -0,0 +1,280 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +"""Pricing that is delegated to website_sale instead of reimplemented. + +Three things this pins down: + +- Eskaera prices with the standard website pricelist. The addon no longer + carries a pricelist setting of its own, nor overrides how the website + resolves one, so `/shop` and Eskaera cannot quote different prices. +- taxes are applied by `product.template._apply_taxes_to_price`, so a fiscal + position that remaps a tax-included tax rebases the price -- the step this + addon used to skip. +- a page of products costs one pricelist resolution, not one per product. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from ..controllers.website_sale import AplicoopWebsiteSale + +REQUEST_PATCH_TARGET = ( + "odoo.addons.website_sale_aplicoop.controllers.website_sale.request" +) + + +class PricingDelegationCommon: + """A company, a website and a tax-included product to price.""" + + def setUp(self): + super().setUp() + self.controller = AplicoopWebsiteSale() + self.company = self.env["res.company"].create({"name": "Pricing Deleg Co"}) + self.website = self.env.ref("website.default_website").sudo() + self.website.write( + { + "company_id": self.company.id, + "show_line_subtotals_tax_selection": "tax_included", + } + ) + self.tax_group = self.env["account.tax.group"].create( + {"name": "IVA Deleg", "company_id": self.company.id} + ) + self.country_es = self.env.ref("base.es") + + def _create_pricelist(self, name, percent=None): + vals = {"name": name, "company_id": self.company.id} + if percent is not None: + vals["item_ids"] = [ + ( + 0, + 0, + { + "compute_price": "percentage", + "percent_price": percent, + "applied_on": "3_global", + }, + ) + ] + return self.env["product.pricelist"].create(vals) + + def _create_tax(self, name, amount, included=False): + return self.env["account.tax"].create( + { + "name": name, + "amount": amount, + "amount_type": "percent", + "type_tax_use": "sale", + "price_include_override": ( + "tax_included" if included else "tax_excluded" + ), + "company_id": self.company.id, + "country_id": self.country_es.id, + "tax_group_id": self.tax_group.id, + } + ) + + def _create_product(self, name, list_price, tax=None): + return self.env["product.product"].create( + { + "name": name, + "type": "consu", + "list_price": list_price, + "is_published": True, + "sale_ok": True, + "company_id": self.company.id, + "taxes_id": [(6, 0, [tax.id])] if tax else False, + } + ) + + +@tagged("post_install", "-at_install") +class TestStandardPricelistResolution(PricingDelegationCommon, TransactionCase): + """Eskaera prices with the website pricelist, like the rest of the shop.""" + + def test_no_aplicoop_pricelist_setting_is_left(self): + """The old per-addon pricelist setting is gone for good. + + It used to let Eskaera quote a different pricelist from `/shop`, which + is exactly the drift this addon should not introduce. + """ + self.assertNotIn( + "aplicoop_pricelist_id", self.env["res.config.settings"]._fields + ) + + def test_website_resolution_is_left_untouched(self): + """`_get_current_pricelist` is core's; the addon does not override it.""" + website_cls = type(self.env["website"]) + + self.assertNotIn("_get_current_pricelist", website_cls.__dict__) + self.assertEqual( + self.website.pricelist_id, self.website._get_current_pricelist() + ) + + def test_the_controller_quotes_the_website_pricelist(self): + """The Eskaera helper hands back exactly what the website resolved.""" + expected = self._create_pricelist("Website PL", percent=15.0) + request_mock = SimpleNamespace( + env=self.env, + website=SimpleNamespace(pricelist_id=expected), + ) + + with patch(REQUEST_PATCH_TARGET, request_mock): + resolved = self.controller._resolve_pricelist() + + self.assertEqual(resolved, expected) + + +@tagged("post_install", "-at_install") +class TestTaxIncludedFiscalPosition(PricingDelegationCommon, TransactionCase): + """A remapped tax-included tax must rebase the displayed price.""" + + def setUp(self): + super().setUp() + self.pricelist = self._create_pricelist("Plain PL") + self.tax_21_incl = self._create_tax("IVA 21% incl", 21.0, included=True) + self.tax_10_incl = self._create_tax("IVA 10% incl", 10.0, included=True) + # 100 net + 21% already inside the price. + self.product = self._create_product("Tax Incl Product", 121.0, self.tax_21_incl) + + def _fiscal_position(self): + position = self.env["account.fiscal.position"].create( + {"name": "Remap 21->10", "company_id": self.company.id} + ) + self.env["account.fiscal.position.tax"].create( + { + "position_id": position.id, + "tax_src_id": self.tax_21_incl.id, + "tax_dest_id": self.tax_10_incl.id, + } + ) + return position + + def test_price_is_rebased_onto_the_mapped_tax(self): + """121 gross at 21% becomes 110 gross at 10%, not 121. + + Without `_get_tax_included_unit_price_from_price` the 121 was handed + straight to the mapped tax, which read it as already being the 10% + gross price and displayed 121. + """ + self.website.fiscal_position_id = self._fiscal_position() + + pricing = self.controller._get_pricing_info( + self.product, self.pricelist, quantity=1.0 + ) + + self.assertAlmostEqual(pricing["price"], 110.0, places=2) + + def test_price_is_untouched_without_a_fiscal_position(self): + """The rebasing is a no-op in the ordinary case.""" + self.website.fiscal_position_id = False + + pricing = self.controller._get_pricing_info( + self.product, self.pricelist, quantity=1.0 + ) + + self.assertAlmostEqual(pricing["price"], 121.0, places=2) + + +@tagged("post_install", "-at_install") +class TestPricingBatching(PricingDelegationCommon, TransactionCase): + """A product listing resolves the pricelist once for the whole page.""" + + def setUp(self): + super().setUp() + self.pricelist = self._create_pricelist("Batch PL", percent=10.0) + self.tax = self._create_tax("IVA 21%", 21.0) + self.products = self.env["product.product"] + for index in range(5): + self.products |= self._create_product( + f"Batch Product {index}", 100.0 + index, self.tax + ) + + def test_pricelist_is_resolved_once_for_the_page(self): + """One `_compute_price_rule` call, not one per product.""" + calls = [] + original = type(self.pricelist)._compute_price_rule + + def counting_compute_price_rule(pricelist_self, products, *args, **kwargs): + calls.append(len(products)) + return original(pricelist_self, products, *args, **kwargs) + + self.patch( + type(self.pricelist), "_compute_price_rule", counting_compute_price_rule + ) + + self.controller._compute_price_info(self.products, self.pricelist) + + self.assertEqual( + calls, + [len(self.products)], + msg=f"Expected a single batched call, got {calls}", + ) + + def test_batched_prices_match_the_single_product_helper(self): + """Batching must not change any number it produces.""" + batched = self.controller._compute_price_info(self.products, self.pricelist) + + for product in self.products: + single = self.controller._get_pricing_info( + product, self.pricelist, quantity=1.0 + ) + self.assertAlmostEqual( + batched[product.id]["price"], single["price"], places=2 + ) + self.assertAlmostEqual( + batched[product.id]["price_unit"], single["price_unit"], places=2 + ) + + def test_prices_the_given_variant(self): + """A variant is priced as itself, not as its template's first one.""" + attribute = self.env["product.attribute"].create( + { + "name": "Size Deleg", + "value_ids": [ + (0, 0, {"name": "Small"}), + (0, 0, {"name": "Large"}), + ], + } + ) + template = self.env["product.template"].create( + { + "name": "Multi Variant Product", + "type": "consu", + "list_price": 100.0, + "is_published": True, + "sale_ok": True, + "company_id": self.company.id, + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": attribute.id, + "value_ids": [(6, 0, attribute.value_ids.ids)], + }, + ) + ], + } + ) + small, large = template.product_variant_ids[0], template.product_variant_ids[1] + # Make the two variants cost visibly different amounts. + large.write({"list_price": 100.0}) + self.env["product.pricelist.item"].create( + { + "pricelist_id": self.pricelist.id, + "applied_on": "0_product_variant", + "product_id": large.id, + "compute_price": "fixed", + "fixed_price": 500.0, + } + ) + + priced = self.controller._compute_price_info(large, self.pricelist) + + self.assertAlmostEqual(priced[large.id]["price_unit"], 500.0, places=2) + self.assertNotEqual(large.id, small.id) diff --git a/website_sale_aplicoop/tests/test_pricing_with_pricelist.py b/website_sale_aplicoop/tests/test_pricing_with_pricelist.py index 8eb4114..d34bb3f 100644 --- a/website_sale_aplicoop/tests/test_pricing_with_pricelist.py +++ b/website_sale_aplicoop/tests/test_pricing_with_pricelist.py @@ -500,8 +500,8 @@ class TestPricingWithPricelist(TransactionCase): # If it raises, that's also acceptable behavior self.assertTrue(True, "Negative quantity properly rejected") - def test_pricing_helper_uses_config_pricelist_and_taxes(self): - """Pricing helper must apply configured pricelist and include taxes for display.""" + def test_pricing_helper_applies_pricelist_and_taxes(self): + """Pricing helper must apply the given pricelist and include taxes.""" website = self.env.ref("website.default_website").sudo() website.write( @@ -529,17 +529,12 @@ class TestPricingWithPricelist(TransactionCase): } ) - self.env["ir.config_parameter"].sudo().set_param( - "website_sale_aplicoop.pricelist_id", pricelist_discount.id - ) - product = self.product_with_tax # 100€ + 21% pricing = self.controller._get_pricing_info( product, pricelist_discount, quantity=1.0, - partner=self.partner, ) # price_unit uses pricelist (10% discount) diff --git a/website_sale_aplicoop/views/res_config_settings_views.xml b/website_sale_aplicoop/views/res_config_settings_views.xml index 5fa5b66..d49b71c 100644 --- a/website_sale_aplicoop/views/res_config_settings_views.xml +++ b/website_sale_aplicoop/views/res_config_settings_views.xml @@ -6,23 +6,6 @@ -

Aplicoop Settings

-
-
-
-
-
-
-

Shop Performance