From 17cc64c522e8ab2f78690ac2fd17be95e05f0c21 Mon Sep 17 00:00:00 2001 From: luis Date: Wed, 1 Oct 2025 13:27:34 +0200 Subject: [PATCH] add stock_inventory_product_exhausted: prevent for removing zero quantity stock quants. Add exhausted products to inventory adjustments --- stock_inventory_product_exhausted/README.rst | 133 ++++++++++ stock_inventory_product_exhausted/__init__.py | 1 + .../__manifest__.py | 16 ++ .../models/__init__.py | 1 + .../models/stock_inventory.py | 113 +++++++++ .../readme/BUGTRACKER.rst | 6 + .../readme/CONFIGURE.rst | 1 + .../readme/CONTRIBUTORS.rst | 21 ++ .../readme/DESCRIPTION.rst | 3 + .../readme/DEVELOPMENT.rst | 3 + .../readme/FEATURES.rst | 6 + .../readme/USAGE.rst | 21 ++ .../tests/__init__.py | 1 + .../tests/test_stock_inventory_exhausted.py | 227 ++++++++++++++++++ .../views/stock_inventory.xml | 12 + 15 files changed, 565 insertions(+) create mode 100644 stock_inventory_product_exhausted/README.rst create mode 100644 stock_inventory_product_exhausted/__init__.py create mode 100644 stock_inventory_product_exhausted/__manifest__.py create mode 100644 stock_inventory_product_exhausted/models/__init__.py create mode 100644 stock_inventory_product_exhausted/models/stock_inventory.py create mode 100644 stock_inventory_product_exhausted/readme/BUGTRACKER.rst create mode 100644 stock_inventory_product_exhausted/readme/CONFIGURE.rst create mode 100644 stock_inventory_product_exhausted/readme/CONTRIBUTORS.rst create mode 100644 stock_inventory_product_exhausted/readme/DESCRIPTION.rst create mode 100644 stock_inventory_product_exhausted/readme/DEVELOPMENT.rst create mode 100644 stock_inventory_product_exhausted/readme/FEATURES.rst create mode 100644 stock_inventory_product_exhausted/readme/USAGE.rst create mode 100644 stock_inventory_product_exhausted/tests/__init__.py create mode 100644 stock_inventory_product_exhausted/tests/test_stock_inventory_exhausted.py create mode 100644 stock_inventory_product_exhausted/views/stock_inventory.xml diff --git a/stock_inventory_product_exhausted/README.rst b/stock_inventory_product_exhausted/README.rst new file mode 100644 index 0000000..bf66833 --- /dev/null +++ b/stock_inventory_product_exhausted/README.rst @@ -0,0 +1,133 @@ +========================================== +Stock Inventory Exhausted Products +========================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:... + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-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%2Fstock--logistics--warehouse-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-warehouse/tree/16.0/stock_inventory_product_exhausted + :alt: OCA/stock-logistics-warehouse +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-16-0/stock-logistics-warehouse-16-0-stock_inventory_product_exhausted + :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/stock-logistics-warehouse&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the functionality of the `stock_inventory` module from the OCA/stock-logistics-warehouse repository to include exhausted products (with zero quantity) in inventory adjustments. + +In recent versions of Odoo, when a product reaches zero available quantity, its `stock.quant` is automatically deleted. This module allows creating quants with zero quantity for exhausted products when creating a new inventory adjustment, facilitating the physical counting of these products. + +**Table of contents** + +.. contents:: + :local: + +Features +======== + +* **Configurable field**: Includes a boolean field `include_exhausted_products` to enable/disable the functionality +* **Default behavior**: Maintains the original behavior of the base module when the option is disabled +* **Support for all selection types**: Works with manual selection, by category, specific product, or all products +* **Storable products only**: Creates quants only for products of type `product`, not for services +* **Prevents duplicates**: Does not create duplicate quants if they already exist for the product and location +* **User interface**: Field visible in the inventory adjustment form + +Configuration +============= + +The module requires no additional configuration. The `include_exhausted_products` field defaults to `False` to maintain compatibility with the original behavior. + +Usage +===== + +To use this module: + +#. Go to **Inventory > Operations > Inventory Adjustments** +#. Create a new inventory adjustment +#. Configure the desired locations and product selection +#. **Enable** the "Include Exhausted Products" field if you want to include products without stock +#. Click on "Start Inventory" + +Use cases +--------- + +**With the field enabled:** + +* Zero quantity quants will be created for products that have no stock in the selected locations +* Allows physically counting products that are exhausted in the system +* Useful for complete inventories where you want to verify that there really is no stock of certain products + +**With the field disabled:** + +* Standard behavior of the original module +* Only products that already have existing quants are included + +Known issues / Roadmap +====================== + +* Review the necessity of the StockQuant class override +* Consider performance optimizations for large product catalogs +* Add option to filter by product categories when including exhausted products + +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 smash it by providing detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Criptomart + +Contributors +~~~~~~~~~~~~ + +* Luis Norte + +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. + +.. |maintainer-luisnore| image:: https://github.com/luisnore.png?size=40px + :target: https://github.com/luisnore + :alt: luisnore + +Current `maintainer `__: + +|maintainer-luisnore| + +This module is part of the `OCA/stock-logistics-warehouse `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_inventory_product_exhausted/__init__.py b/stock_inventory_product_exhausted/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/stock_inventory_product_exhausted/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_inventory_product_exhausted/__manifest__.py b/stock_inventory_product_exhausted/__manifest__.py new file mode 100644 index 0000000..e443b78 --- /dev/null +++ b/stock_inventory_product_exhausted/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "Stock Inventory Exhausted Products", + "version": "16.0.1.0.0", + "development_status": "Beta", + "depends": ["stock_inventory"], + "author": "Criptomart, Odoo Community Association (OCA)", + "maintainers": ["luisnore"], + "website": "https://github.com/OCA/stock-logistics-warehouse", + "license": "AGPL-3", + "category": "Warehouse", + "summary": "Create quants for products with zero quantity in stock adjustments", + "data": [ + "views/stock_inventory.xml", + ], + "installable": True, +} diff --git a/stock_inventory_product_exhausted/models/__init__.py b/stock_inventory_product_exhausted/models/__init__.py new file mode 100644 index 0000000..3553681 --- /dev/null +++ b/stock_inventory_product_exhausted/models/__init__.py @@ -0,0 +1 @@ +from . import stock_inventory diff --git a/stock_inventory_product_exhausted/models/stock_inventory.py b/stock_inventory_product_exhausted/models/stock_inventory.py new file mode 100644 index 0000000..d384c88 --- /dev/null +++ b/stock_inventory_product_exhausted/models/stock_inventory.py @@ -0,0 +1,113 @@ +# Copyright 2025 Your Name or Company +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, models, api, fields +import logging + +_logger = logging.getLogger(__name__) + + +class StockInventory(models.Model): + _inherit = "stock.inventory" + + include_exhausted_products = fields.Boolean( + string="Include Exhausted Products", + help="If enabled, products with zero stock will be included " + "in the inventory adjustment by creating zero-quantity quants.", + default=False, + ) + + def _create_zero_quants(self, locations, products): + """Create zero-quantity quants for products without existing quants. + + This method creates stock quants with zero quantity for storable products + that don't have existing quants in the specified locations. This allows + these products to be included in inventory adjustments even when they + have no current stock. + + Args: + locations (stock.location recordset): Locations to check + products (product.product recordset): Products to process + """ + Quant = self.env["stock.quant"] + + # Get existing quants to avoid duplicates + existing_quants = Quant.search( + [ + ("product_id", "in", products.ids), + ("location_id", "in", locations.ids), + ] + ) + existing_combinations = { + (q.product_id.id, q.location_id.id) for q in existing_quants + } + + quants_to_create = [] + for location in locations: + for product in products.filtered(lambda p: p.type == "product"): + if (product.id, location.id) not in existing_combinations: + _logger.debug( + "Creating zero quant for product %s in location %s", + product.name, + location.name, + ) + quants_to_create.append( + { + "product_id": product.id, + "location_id": location.id, + "quantity": 0, + "company_id": location.company_id.id or self.env.company.id, + } + ) + + if quants_to_create: + Quant.create(quants_to_create) + + def _get_quants(self, locations): + """Override to include zero-quantity quants when requested. + + This method extends the base functionality to optionally include + products with zero stock by creating zero-quantity quants before + retrieving the final quant list. + + Args: + locations (stock.location recordset): Locations for inventory + + Returns: + stock.quant recordset: Quants to include in inventory + """ + quants = super()._get_quants(locations) + + if self.include_exhausted_products: + # Determine products based on selection criteria + if self.product_selection == "category": + products = self.env["product.product"].search( + [("categ_id", "child_of", self.category_id.id)] + ) + elif self.product_selection in ("manual", "one", "lot"): + products = self.product_ids + else: # "all" + products = self.env["product.product"].search( + [("type", "=", "product")] + ) + + self._create_zero_quants(locations, products) + # Re-fetch quants to include newly created ones + return super()._get_quants(locations) + + return quants + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + @api.model + def _unlink_zero_quants(self): + """Prevent automatic unlinking of zero quants. + + This method overrides the default behavior to prevent + zero quants from being automatically removed. + This may need review to ensure it doesn't conflict + with standard Odoo behavior. + """ + _logger.debug("Preventing automatic unlinking of zero quants") diff --git a/stock_inventory_product_exhausted/readme/BUGTRACKER.rst b/stock_inventory_product_exhausted/readme/BUGTRACKER.rst new file mode 100644 index 0000000..3022a23 --- /dev/null +++ b/stock_inventory_product_exhausted/readme/BUGTRACKER.rst @@ -0,0 +1,6 @@ +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 smash it by providing detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. diff --git a/stock_inventory_product_exhausted/readme/CONFIGURE.rst b/stock_inventory_product_exhausted/readme/CONFIGURE.rst new file mode 100644 index 0000000..d00d495 --- /dev/null +++ b/stock_inventory_product_exhausted/readme/CONFIGURE.rst @@ -0,0 +1 @@ +The module requires no additional configuration. The `include_exhausted_products` field defaults to `False` to maintain compatibility with the original behavior. diff --git a/stock_inventory_product_exhausted/readme/CONTRIBUTORS.rst b/stock_inventory_product_exhausted/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..e9dc53c --- /dev/null +++ b/stock_inventory_product_exhausted/readme/CONTRIBUTORS.rst @@ -0,0 +1,21 @@ +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. + +.. |maintainer-yourusername| image:: https://github.com/yourusername.png?size=40px + :target: https://github.com/yourusername + :alt: yourusername + +Current `maintainer `__: + +|maintainer-yourusername| + +This module is part of the `OCA/stock-logistics-warehouse `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_inventory_product_exhausted/readme/DESCRIPTION.rst b/stock_inventory_product_exhausted/readme/DESCRIPTION.rst new file mode 100644 index 0000000..34fa49d --- /dev/null +++ b/stock_inventory_product_exhausted/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module extends the functionality of the `stock_inventory` module from the OCA/stock-logistics-warehouse repository to include exhausted products (with zero quantity) in inventory adjustments. + +In recent versions of Odoo, when a product reaches zero available quantity, its `stock.quant` is automatically deleted. This module allows creating quants with zero quantity for exhausted products when creating a new inventory adjustment, facilitating the physical counting of these products. diff --git a/stock_inventory_product_exhausted/readme/DEVELOPMENT.rst b/stock_inventory_product_exhausted/readme/DEVELOPMENT.rst new file mode 100644 index 0000000..e05aafb --- /dev/null +++ b/stock_inventory_product_exhausted/readme/DEVELOPMENT.rst @@ -0,0 +1,3 @@ +This is a beta version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +`More details on development status `_ diff --git a/stock_inventory_product_exhausted/readme/FEATURES.rst b/stock_inventory_product_exhausted/readme/FEATURES.rst new file mode 100644 index 0000000..90ee66d --- /dev/null +++ b/stock_inventory_product_exhausted/readme/FEATURES.rst @@ -0,0 +1,6 @@ +* **Configurable field**: Includes a boolean field `include_exhausted_products` to enable/disable the functionality +* **Default behavior**: Maintains the original behavior of the base module when the option is disabled +* **Support for all selection types**: Works with manual selection, by category, specific product, or all products +* **Storable products only**: Creates quants only for products of type `product`, not for services +* **Prevents duplicates**: Does not create duplicate quants if they already exist for the product and location +* **User interface**: Field visible in the inventory adjustment form diff --git a/stock_inventory_product_exhausted/readme/USAGE.rst b/stock_inventory_product_exhausted/readme/USAGE.rst new file mode 100644 index 0000000..def25dc --- /dev/null +++ b/stock_inventory_product_exhausted/readme/USAGE.rst @@ -0,0 +1,21 @@ +To use this module: + +#. Go to **Inventory > Operations > Inventory Adjustments** +#. Create a new inventory adjustment +#. Configure the desired locations and product selection +#. **Enable** the "Include Exhausted Products" field if you want to include products without stock +#. Click on "Start Inventory" + +Use cases +--------- + +**With the field enabled:** + +* Zero quantity quants will be created for products that have no stock in the selected locations +* Allows physically counting products that are exhausted in the system +* Useful for complete inventories where you want to verify that there really is no stock of certain products + +**With the field disabled:** + +* Standard behavior of the original module +* Only products that already have existing quants are included diff --git a/stock_inventory_product_exhausted/tests/__init__.py b/stock_inventory_product_exhausted/tests/__init__.py new file mode 100644 index 0000000..f5e8fea --- /dev/null +++ b/stock_inventory_product_exhausted/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_inventory_exhausted diff --git a/stock_inventory_product_exhausted/tests/test_stock_inventory_exhausted.py b/stock_inventory_product_exhausted/tests/test_stock_inventory_exhausted.py new file mode 100644 index 0000000..9018965 --- /dev/null +++ b/stock_inventory_product_exhausted/tests/test_stock_inventory_exhausted.py @@ -0,0 +1,227 @@ +from odoo.tests.common import TransactionCase + + +class TestStockInventoryExhausted(TransactionCase): + def setUp(self): + super().setUp() + # Create test products + self.product_with_stock = self.env["product.product"].create( + { + "name": "Product With Stock", + "type": "product", + "categ_id": self.env.ref("product.product_category_all").id, + } + ) + + self.product_without_stock = self.env["product.product"].create( + { + "name": "Product Without Stock", + "type": "product", + "categ_id": self.env.ref("product.product_category_all").id, + } + ) + + # Create test location + self.location = self.env["stock.location"].create( + { + "name": "Test Location", + "usage": "internal", + } + ) + + # Create initial stock for one product + self.env["stock.quant"].create( + { + "product_id": self.product_with_stock.id, + "location_id": self.location.id, + "quantity": 10.0, + } + ) + + def test_inventory_without_exhausted_products_flag(self): + """Test that without the flag, zero stock products are not included""" + inventory = self.env["stock.inventory"].create( + { + "name": "Test Inventory without flag", + "location_ids": [(6, 0, [self.location.id])], + "product_selection": "all", + "include_exhausted_products": False, + } + ) + + inventory.action_start() + + # Should only have quants for products with existing stock + self.assertTrue( + any( + q.product_id == self.product_with_stock + for q in inventory.stock_quant_ids + ) + ) + self.assertFalse( + any( + q.product_id == self.product_without_stock + for q in inventory.stock_quant_ids + ) + ) + + def test_inventory_with_exhausted_products_flag(self): + """Test that with the flag, zero stock products are included""" + inventory = self.env["stock.inventory"].create( + { + "name": "Test Inventory with flag", + "location_ids": [(6, 0, [self.location.id])], + "product_selection": "all", + "include_exhausted_products": True, + } + ) + + inventory.action_start() + + # Should have quants for both products (including zero stock product) + self.assertTrue( + any( + q.product_id == self.product_with_stock + for q in inventory.stock_quant_ids + ) + ) + self.assertTrue( + any( + q.product_id == self.product_without_stock + for q in inventory.stock_quant_ids + ) + ) + + # Check that zero stock product has quantity 0 + zero_quant = inventory.stock_quant_ids.filtered( + lambda q: q.product_id == self.product_without_stock + ) + self.assertEqual(zero_quant.quantity, 0.0) + + def test_inventory_manual_selection_with_exhausted_flag(self): + """Test manual product selection with exhausted products flag""" + inventory = self.env["stock.inventory"].create( + { + "name": "Test Manual Selection", + "location_ids": [(6, 0, [self.location.id])], + "product_selection": "manual", + "product_ids": [(6, 0, [self.product_without_stock.id])], + "include_exhausted_products": True, + } + ) + + inventory.action_start() + + # Should have quant for the selected product even if it has zero stock + self.assertTrue( + any( + q.product_id == self.product_without_stock + for q in inventory.stock_quant_ids + ) + ) + zero_quant = inventory.stock_quant_ids.filtered( + lambda q: q.product_id == self.product_without_stock + ) + self.assertEqual(zero_quant.quantity, 0.0) + + def test_inventory_category_selection_with_exhausted_flag(self): + """Test category selection with exhausted products flag""" + # Create a specific category + test_category = self.env["product.category"].create({"name": "Test Category"}) + + # Create product in this category + product_in_category = self.env["product.product"].create( + { + "name": "Product In Category", + "type": "product", + "categ_id": test_category.id, + } + ) + + inventory = self.env["stock.inventory"].create( + { + "name": "Test Category Selection", + "location_ids": [(6, 0, [self.location.id])], + "product_selection": "category", + "category_id": test_category.id, + "include_exhausted_products": True, + } + ) + + inventory.action_start() + + # Should have quant for the product in category even if it has zero stock + self.assertTrue( + any(q.product_id == product_in_category for q in inventory.stock_quant_ids) + ) + + def test_create_zero_quants_only_for_product_type(self): + """Test that zero quants are only created for products with type 'product'""" + # Create a service product + service_product = self.env["product.product"].create( + { + "name": "Service Product", + "type": "service", + } + ) + + inventory = self.env["stock.inventory"].create( + { + "name": "Test Service Product", + "location_ids": [(6, 0, [self.location.id])], + "product_selection": "manual", + "product_ids": [(6, 0, [service_product.id])], + "include_exhausted_products": True, + } + ) + + inventory.action_start() + + # Should not create quants for service products + self.assertFalse( + any(q.product_id == service_product for q in inventory.stock_quant_ids) + ) + + def test_no_duplicate_quants_created(self): + """Test that no duplicate quants are created if they already exist""" + # Create a quant with zero quantity manually + existing_quant = self.env["stock.quant"].create( + { + "product_id": self.product_without_stock.id, + "location_id": self.location.id, + "quantity": 0.0, + } + ) + + inventory = self.env["stock.inventory"].create( + { + "name": "Test No Duplicates", + "location_ids": [(6, 0, [self.location.id])], + "product_selection": "manual", + "product_ids": [(6, 0, [self.product_without_stock.id])], + "include_exhausted_products": True, + } + ) + + inventory.action_start() + + # Should use existing quant, not create a new one + quants_for_product = self.env["stock.quant"].search( + [ + ("product_id", "=", self.product_without_stock.id), + ("location_id", "=", self.location.id), + ] + ) + self.assertEqual(len(quants_for_product), 1) + self.assertEqual(quants_for_product.id, existing_quant.id) + + def test_field_default_value(self): + """Test that include_exhausted_products field defaults to False""" + inventory = self.env["stock.inventory"].create( + { + "name": "Test Default Value", + "location_ids": [(6, 0, [self.location.id])], + } + ) + + self.assertFalse(inventory.include_exhausted_products) diff --git a/stock_inventory_product_exhausted/views/stock_inventory.xml b/stock_inventory_product_exhausted/views/stock_inventory.xml new file mode 100644 index 0000000..021af95 --- /dev/null +++ b/stock_inventory_product_exhausted/views/stock_inventory.xml @@ -0,0 +1,12 @@ + + + stock.inventory.form.exhausted + stock.inventory + + + + + + + + \ No newline at end of file