diff --git a/website_sale_stock_location_filter/README.rst b/website_sale_stock_location_filter/README.rst new file mode 100644 index 0000000000..e4e166a567 --- /dev/null +++ b/website_sale_stock_location_filter/README.rst @@ -0,0 +1,92 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================== +Website Sale Stock Location Filter +================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9c6675b4355bfe166b7c021df03bc8dcd03cbd5efd3073862a6f824109020476 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fe--commerce-lightgray.png?logo=github + :target: https://github.com/OCA/e-commerce/tree/19.0/website_sale_stock_location_filter + :alt: OCA/e-commerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-commerce-19-0/e-commerce-19-0-website_sale_stock_location_filter + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/e-commerce&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module lets you choose, per stock location, whether its on-hand +quantity is counted towards the stock quantity displayed on the website +shop. + +By default every internal location contributes to the displayed stock. +Internal locations that should not be shown to website visitors (for +example technician locations) can be flagged with **Exclude from Website +Stock** on the location form; their quantity is then left out of the +quantity shown on product pages. + +The displayed quantity keeps using ``free_qty`` (on hand minus +reserved), exactly as standard Odoo does, restricted to the non-excluded +internal locations of the website's warehouse. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow + +Contributors +------------ + +- Miquel Raïch + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/e-commerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_stock_location_filter/__init__.py b/website_sale_stock_location_filter/__init__.py new file mode 100644 index 0000000000..c474fc5798 --- /dev/null +++ b/website_sale_stock_location_filter/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/website_sale_stock_location_filter/__manifest__.py b/website_sale_stock_location_filter/__manifest__.py new file mode 100644 index 0000000000..af3acd698e --- /dev/null +++ b/website_sale_stock_location_filter/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Website Sale Stock Location Filter", + "summary": "Choose which stock locations count towards the stock " + "quantity displayed on the website shop.", + "version": "19.0.1.0.0", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/e-commerce", + "category": "Website/Website", + "depends": [ + "website_sale_stock", + ], + "data": [ + "views/stock_location_views.xml", + ], + "license": "AGPL-3", + "installable": True, +} diff --git a/website_sale_stock_location_filter/models/__init__.py b/website_sale_stock_location_filter/models/__init__.py new file mode 100644 index 0000000000..c0d64dcde6 --- /dev/null +++ b/website_sale_stock_location_filter/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import stock_location +from . import website diff --git a/website_sale_stock_location_filter/models/stock_location.py b/website_sale_stock_location_filter/models/stock_location.py new file mode 100644 index 0000000000..6cd8089c73 --- /dev/null +++ b/website_sale_stock_location_filter/models/stock_location.py @@ -0,0 +1,16 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockLocation(models.Model): + _inherit = "stock.location" + + exclude_from_website_stock = fields.Boolean( + string="Exclude from Website Stock", + default=False, + help="If set, the on-hand quantity in this location is NOT counted " + "towards the stock quantity displayed on the website shop. " + "Only applies to internal locations.", + ) diff --git a/website_sale_stock_location_filter/models/website.py b/website_sale_stock_location_filter/models/website.py new file mode 100644 index 0000000000..117614b155 --- /dev/null +++ b/website_sale_stock_location_filter/models/website.py @@ -0,0 +1,42 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class Website(models.Model): + _inherit = "website" + + def _get_website_stock_locations(self): + """Return the internal locations whose stock is published on the shop. + + Internal locations flagged with ``exclude_from_website_stock`` (e.g. + technician locations) are left out. When the website has a warehouse, + only that warehouse's locations are considered; otherwise all the + internal locations of the active companies are used (mirroring the + default behaviour of ``free_qty`` without a warehouse context). + + Runs sudo: the shop exposes stock to public/portal visitors, who have + no read access to ``stock.location``. Standard ``free_qty`` never reads + locations directly (it goes through warehouses and sudoes the quants), + so resolving the location set ourselves needs the same elevation. + """ + self.ensure_one() + domain = [ + ("usage", "=", "internal"), + ("exclude_from_website_stock", "=", False), + ] + if self.warehouse_id: + domain.append(("id", "child_of", self.warehouse_id.view_location_id.id)) + else: + domain.append(("company_id", "in", self.env.companies.ids)) + return self.env["stock.location"].sudo().search(domain) + + def _get_product_available_qty(self, product, **kwargs): + locations = self._get_website_stock_locations() + if not locations: + return 0.0 + # ``strict=True`` makes ``free_qty`` count exactly these locations, + # without re-expanding to their children, so excluded sub-locations + # stay excluded. See stock.product._get_domain_locations_new. + return product.with_context(location=locations.ids, strict=True).free_qty diff --git a/website_sale_stock_location_filter/pyproject.toml b/website_sale_stock_location_filter/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/website_sale_stock_location_filter/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_stock_location_filter/readme/CONTRIBUTORS.md b/website_sale_stock_location_filter/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..1da3d73e29 --- /dev/null +++ b/website_sale_stock_location_filter/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Miquel Raïch \<\> diff --git a/website_sale_stock_location_filter/readme/DESCRIPTION.md b/website_sale_stock_location_filter/readme/DESCRIPTION.md new file mode 100644 index 0000000000..ddad653686 --- /dev/null +++ b/website_sale_stock_location_filter/readme/DESCRIPTION.md @@ -0,0 +1,13 @@ +This module lets you choose, per stock location, whether its on-hand +quantity is counted towards the stock quantity displayed on the website +shop. + +By default every internal location contributes to the displayed stock. +Internal locations that should not be shown to website visitors (for +example technician locations) can be flagged with **Exclude from Website +Stock** on the location form; their quantity is then left out of the +quantity shown on product pages. + +The displayed quantity keeps using `free_qty` (on hand minus reserved), +exactly as standard Odoo does, restricted to the non-excluded internal +locations of the website's warehouse. diff --git a/website_sale_stock_location_filter/static/description/index.html b/website_sale_stock_location_filter/static/description/index.html new file mode 100644 index 0000000000..ef97df433e --- /dev/null +++ b/website_sale_stock_location_filter/static/description/index.html @@ -0,0 +1,439 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Website Sale Stock Location Filter

+ +

Beta License: AGPL-3 OCA/e-commerce Translate me on Weblate Try me on Runboat

+

This module lets you choose, per stock location, whether its on-hand +quantity is counted towards the stock quantity displayed on the website +shop.

+

By default every internal location contributes to the displayed stock. +Internal locations that should not be shown to website visitors (for +example technician locations) can be flagged with Exclude from Website +Stock on the location form; their quantity is then left out of the +quantity shown on product pages.

+

The displayed quantity keeps using free_qty (on hand minus +reserved), exactly as standard Odoo does, restricted to the non-excluded +internal locations of the website’s warehouse.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/e-commerce project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/website_sale_stock_location_filter/tests/__init__.py b/website_sale_stock_location_filter/tests/__init__.py new file mode 100644 index 0000000000..aa7dc9234d --- /dev/null +++ b/website_sale_stock_location_filter/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_website_stock_location_filter diff --git a/website_sale_stock_location_filter/tests/test_website_stock_location_filter.py b/website_sale_stock_location_filter/tests/test_website_stock_location_filter.py new file mode 100644 index 0000000000..92ab9ca308 --- /dev/null +++ b/website_sale_stock_location_filter/tests/test_website_stock_location_filter.py @@ -0,0 +1,57 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestWebsiteStockLocationFilter(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.warehouse = cls.env["stock.warehouse"].search( + [("company_id", "=", cls.env.company.id)], limit=1 + ) + cls.stock_location = cls.warehouse.lot_stock_id + # Technician location is a *child* of the warehouse stock location, so + # excluding it also exercises the `strict` no-child-re-expansion path. + cls.technician_location = cls.env["stock.location"].create( + { + "name": "Technician", + "usage": "internal", + "location_id": cls.stock_location.id, + } + ) + cls.product = cls.env["product.product"].create( + { + "name": "Test Storable Product", + "type": "consu", + "is_storable": True, + } + ) + cls.website = cls.env["website"].search([], limit=1) + cls.website.warehouse_id = cls.warehouse + Quant = cls.env["stock.quant"] + Quant._update_available_quantity(cls.product, cls.stock_location, 10.0) + Quant._update_available_quantity(cls.product, cls.technician_location, 5.0) + + def test_all_locations_included_by_default(self): + qty = self.website._get_product_available_qty(self.product) + self.assertEqual(qty, 15.0, "Both internal locations should be counted") + + def test_excluded_child_location_not_counted(self): + self.technician_location.exclude_from_website_stock = True + qty = self.website._get_product_available_qty(self.product) + self.assertEqual( + qty, + 10.0, + "Excluded child location must not be re-included via its parent", + ) + + def test_reserved_quantity_is_subtracted(self): + # free_qty must remain the basis: reserve 3 from the visible stock. + self.env["stock.quant"]._update_reserved_quantity( + self.product, self.stock_location, 3.0 + ) + self.technician_location.exclude_from_website_stock = True + qty = self.website._get_product_available_qty(self.product) + self.assertEqual(qty, 7.0, "Reserved quantity must be subtracted (free_qty)") diff --git a/website_sale_stock_location_filter/views/stock_location_views.xml b/website_sale_stock_location_filter/views/stock_location_views.xml new file mode 100644 index 0000000000..0d26e8b61b --- /dev/null +++ b/website_sale_stock_location_filter/views/stock_location_views.xml @@ -0,0 +1,18 @@ + + + + + stock.location.form.website.stock.filter + stock.location + + + + + + + +