diff --git a/setup/stock_inventory_security/odoo/addons/stock_inventory_security b/setup/stock_inventory_security/odoo/addons/stock_inventory_security
new file mode 120000
index 000000000000..f9e25cd7746f
--- /dev/null
+++ b/setup/stock_inventory_security/odoo/addons/stock_inventory_security
@@ -0,0 +1 @@
+../../../../stock_inventory_security
\ No newline at end of file
diff --git a/setup/stock_inventory_security/setup.py b/setup/stock_inventory_security/setup.py
new file mode 100644
index 000000000000..28c57bb64031
--- /dev/null
+++ b/setup/stock_inventory_security/setup.py
@@ -0,0 +1,6 @@
+import setuptools
+
+setuptools.setup(
+ setup_requires=['setuptools-odoo'],
+ odoo_addon=True,
+)
diff --git a/stock_inventory_security/README.rst b/stock_inventory_security/README.rst
new file mode 100644
index 000000000000..c26961f4e147
--- /dev/null
+++ b/stock_inventory_security/README.rst
@@ -0,0 +1 @@
+# To be generated by bot
diff --git a/stock_inventory_security/__init__.py b/stock_inventory_security/__init__.py
new file mode 100644
index 000000000000..0650744f6bc6
--- /dev/null
+++ b/stock_inventory_security/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/stock_inventory_security/__manifest__.py b/stock_inventory_security/__manifest__.py
new file mode 100644
index 000000000000..6f1e552ede74
--- /dev/null
+++ b/stock_inventory_security/__manifest__.py
@@ -0,0 +1,23 @@
+# Copyright 2024 Camptocamp SA (https://www.camptocamp.com).
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+{
+ "name": "Stock Quant Inventory Security",
+ "summary": "Dedicated security group to apply inventory adjustments",
+ "version": "16.0.1.0.0",
+ "author": "Camptocamp, Odoo Community Association (OCA)",
+ "maintainers": ["ivantodorovich"],
+ "website": "https://github.com/OCA/stock-logistics-warehouse",
+ "license": "AGPL-3",
+ "category": "Inventory",
+ "depends": ["stock"],
+ "data": [
+ "security/security.xml",
+ "security/ir.model.access.csv",
+ "views/product.xml",
+ "views/stock_quant.xml",
+ ],
+ "demo": [
+ "demo/demo.xml",
+ ],
+}
diff --git a/stock_inventory_security/demo/demo.xml b/stock_inventory_security/demo/demo.xml
new file mode 100644
index 000000000000..c5dd2f94d928
--- /dev/null
+++ b/stock_inventory_security/demo/demo.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/stock_inventory_security/models/__init__.py b/stock_inventory_security/models/__init__.py
new file mode 100644
index 000000000000..d979564a36df
--- /dev/null
+++ b/stock_inventory_security/models/__init__.py
@@ -0,0 +1,4 @@
+from . import product_product
+from . import stock_location
+from . import stock_lot
+from . import stock_quant
diff --git a/stock_inventory_security/models/product_product.py b/stock_inventory_security/models/product_product.py
new file mode 100644
index 000000000000..390465aaa8d1
--- /dev/null
+++ b/stock_inventory_security/models/product_product.py
@@ -0,0 +1,23 @@
+# Copyright 2024 Camptocamp SA (https://www.camptocamp.com).
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import models
+
+
+class ProductProduct(models.Model):
+ _inherit = "product.product"
+
+ def user_has_groups(self, groups):
+ # Most inventory adjustment operations are limited to users having
+ # the Inventory Manager group.
+ # OVERRIDE: Hijack the check to replace it with our own group.
+ if groups == "stock.group_stock_manager" and self.env.context.get(
+ "_stock_inventory_security"
+ ):
+ groups = "stock_inventory_security.group_inventory_adjustment"
+ return super().user_has_groups(groups)
+
+ def action_open_quants(self):
+ if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"):
+ self = self.with_context(_stock_inventory_security=True)
+ return super().action_open_quants()
diff --git a/stock_inventory_security/models/stock_location.py b/stock_inventory_security/models/stock_location.py
new file mode 100644
index 000000000000..1ff8deef8e11
--- /dev/null
+++ b/stock_inventory_security/models/stock_location.py
@@ -0,0 +1,22 @@
+# Copyright 2024 Camptocamp SA (https://www.camptocamp.com).
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import models
+
+
+class StockLocation(models.Model):
+ _inherit = "stock.location"
+
+ def write(self, vals):
+ # OVERRIDE: Allow the inventory user to set the last inventory date.
+ # https://github.com/odoo/odoo/blob/534220ee/addons/stock/models/stock_quant.py#L775
+ if (
+ self.env.context.get("_stock_inventory_security")
+ and len(vals) == 1
+ and "last_inventory_date" in vals
+ and self.user_has_groups(
+ "stock_inventory_security.group_inventory_adjustment"
+ )
+ ):
+ self = self.sudo()
+ return super().write(vals)
diff --git a/stock_inventory_security/models/stock_lot.py b/stock_inventory_security/models/stock_lot.py
new file mode 100644
index 000000000000..75812fecfb2a
--- /dev/null
+++ b/stock_inventory_security/models/stock_lot.py
@@ -0,0 +1,24 @@
+# Copyright 2024 Camptocamp SA (https://www.camptocamp.com).
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import models
+
+
+class StockLot(models.Model):
+ _inherit = "stock.lot"
+
+ def user_has_groups(self, groups):
+ # Most inventory adjustment operations are limited to users having
+ # the Inventory Manager group.
+ # OVERRIDE: Hijack the check to replace it with our own group.)
+ if groups == "stock.group_stock_manager" and self.env.context.get(
+ "_stock_inventory_security"
+ ):
+ groups = "stock_inventory_security.group_inventory_adjustment"
+ return super().user_has_groups(groups)
+
+ def action_lot_open_quants(self):
+ # OVERRIDE: Add the inventory_mode context
+ if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"):
+ self = self.with_context(_stock_inventory_security=True)
+ return super().action_lot_open_quants()
diff --git a/stock_inventory_security/models/stock_quant.py b/stock_inventory_security/models/stock_quant.py
new file mode 100644
index 000000000000..452c13fe3417
--- /dev/null
+++ b/stock_inventory_security/models/stock_quant.py
@@ -0,0 +1,43 @@
+# Copyright 2024 Camptocamp SA (https://www.camptocamp.com).
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import fields, models
+
+
+class StockQuant(models.Model):
+ _inherit = "stock.quant"
+
+ inventory_quantity_auto_apply = fields.Float(
+ # Change stock.group_stock_manager to our own group.
+ groups="stock_inventory_security.group_inventory_adjustment",
+ )
+
+ def user_has_groups(self, groups):
+ # Most inventory adjustment operations are limited to users having
+ # the Inventory Manager group.
+ # OVERRIDE: Hijack the check to replace it with our own group.
+ if groups == "stock.group_stock_manager" and self.env.context.get(
+ "_stock_inventory_security"
+ ):
+ groups = "stock_inventory_security.group_inventory_adjustment"
+ return super().user_has_groups(groups)
+
+ def _get_quants_action(self, domain=None, extend=False):
+ # OVERRIDE: Show the editable quants view for users having the Inventory
+ # Adjustments group.
+ # The original method would only do it for Stock Managers.
+ if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"):
+ self = self.with_context(_stock_inventory_security=True)
+ return super()._get_quants_action(domain=domain, extend=extend)
+
+ def action_view_inventory(self):
+ # OVERRIDE: Disable the "My count" filter for users having the Inventory
+ # Adjustments group.
+ if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"):
+ self = self.with_context(_stock_inventory_security=True)
+ return super().action_view_inventory()
+
+ def _apply_inventory(self):
+ if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"):
+ self = self.with_context(_stock_inventory_security=True)
+ return super()._apply_inventory()
diff --git a/stock_inventory_security/readme/CONTRIBUTORS.md b/stock_inventory_security/readme/CONTRIBUTORS.md
new file mode 100644
index 000000000000..7992345c3cce
--- /dev/null
+++ b/stock_inventory_security/readme/CONTRIBUTORS.md
@@ -0,0 +1 @@
+* Iván Todorovich
diff --git a/stock_inventory_security/readme/README.md b/stock_inventory_security/readme/README.md
new file mode 100644
index 000000000000..c60cf67ac8f6
--- /dev/null
+++ b/stock_inventory_security/readme/README.md
@@ -0,0 +1,4 @@
+In standard, the inventory adjustments can only be applied by **Inventory / Manager** users.
+
+This module introduces a new security group named **Stock: Inventory Adjustments**, which
+grants regular stock users the ability to apply inventory adjustments.
diff --git a/stock_inventory_security/security/ir.model.access.csv b/stock_inventory_security/security/ir.model.access.csv
new file mode 100644
index 000000000000..cafc1a2b43ae
--- /dev/null
+++ b/stock_inventory_security/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_stock_inventory_adjustment_name,stock.inventory.adjustment.name,stock.model_stock_inventory_adjustment_name,group_inventory_adjustment,1,1,1,0
+access_stock_request_count,stock.request.count,stock.model_stock_request_count,group_inventory_adjustment,1,1,1,0
diff --git a/stock_inventory_security/security/security.xml b/stock_inventory_security/security/security.xml
new file mode 100644
index 000000000000..6f28befc83ea
--- /dev/null
+++ b/stock_inventory_security/security/security.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ Stock: Inventory Adjustments
+
+
+
+
+
+
+
+
diff --git a/stock_inventory_security/tests/__init__.py b/stock_inventory_security/tests/__init__.py
new file mode 100644
index 000000000000..acaff09facdd
--- /dev/null
+++ b/stock_inventory_security/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_stock_inventory_security
diff --git a/stock_inventory_security/tests/test_stock_inventory_security.py b/stock_inventory_security/tests/test_stock_inventory_security.py
new file mode 100644
index 000000000000..76e78153a8d9
--- /dev/null
+++ b/stock_inventory_security/tests/test_stock_inventory_security.py
@@ -0,0 +1,156 @@
+# Copyright 2024 Camptocamp SA (https://www.camptocamp.com).
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo.exceptions import UserError
+from odoo.tests import Form, TransactionCase, new_test_user, users
+
+from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT
+
+
+class TestStockInventorySecurity(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.env = cls.env(context=dict(cls.env.context, **DISABLED_MAIL_CONTEXT))
+ # Lazy tests compatibility with `stock_inventory_discrepancy`
+ cls.env = cls.env(context=dict(cls.env.context, skip_exceeded_discrepancy=True))
+ # Create test records
+ cls.inventory_user = new_test_user(
+ cls.env,
+ login="inventory",
+ groups="stock_inventory_security.group_inventory_adjustment",
+ )
+ cls.stock_user = new_test_user(
+ cls.env,
+ login="stock",
+ groups="stock.group_stock_user",
+ )
+ cls.stock_location = cls.env.ref("stock.stock_location_stock")
+ cls.product = cls.env["product.product"].create(
+ {
+ "name": "Test product",
+ "type": "product",
+ }
+ )
+ cls.product_lot = cls.env["stock.lot"].create(
+ {
+ "product_id": cls.product.id,
+ "company_id": cls.env.company.id,
+ }
+ )
+ cls.product_quants = (
+ cls.env["stock.quant"]
+ .with_context(inventory_mode=True)
+ .create(
+ {
+ "product_id": cls.product.id,
+ "inventory_quantity": 4,
+ "lot_id": cls.product_lot.id,
+ "location_id": cls.stock_location.id,
+ }
+ )
+ )
+ cls.product_quants.action_apply_inventory()
+
+ @users("inventory", "admin")
+ def test_inventory_user_product_action_open_quants(self):
+ """Test that the inventory user gets into inventory mode from products"""
+ res = self.product.with_user(self.env.user).action_open_quants()
+ self.assertFalse(res["context"].get("search_default_my_count"))
+
+ @users("stock")
+ def test_stock_user_product_action_open_quants(self):
+ """Test that the stock user does not get into inventory mode from products"""
+ res = self.product.with_user(self.env.user).action_open_quants()
+ self.assertTrue(res["context"].get("search_default_my_count"))
+
+ @users("inventory", "admin")
+ def test_inventory_user_quant_action(self):
+ """Test that the inventory user gets into inventory mode from quants"""
+ res = self.product_quants.with_user(self.env.user).action_view_inventory()
+ self.assertFalse(res["context"].get("search_default_my_count"))
+
+ @users("stock")
+ def test_stock_user_quant_action(self):
+ """Test that the stock user does not get into inventory mode from quants"""
+ res = self.product_quants.with_user(self.env.user).action_view_inventory()
+ self.assertTrue(res["context"].get("search_default_my_count"))
+
+ @users("inventory", "admin")
+ def test_inventory_user_lot_action(self):
+ """Test that the inventory user gets into inventory mode from lots"""
+ res = self.product_lot.with_user(self.env.user).action_lot_open_quants()
+ self.assertEqual(
+ res["view_id"], self.env.ref("stock.view_stock_quant_tree_editable").id
+ )
+ self.assertTrue(res["context"].get("inventory_mode"))
+
+ @users("stock")
+ def test_stock_user_lot_action(self):
+ """Test that the stock user does not get into inventory mode from lots"""
+ res = self.product_lot.with_user(self.stock_user).action_lot_open_quants()
+ self.assertEqual(res["view_id"], self.env.ref("stock.view_stock_quant_tree").id)
+ self.assertFalse(res["context"].get("inventory_mode"))
+
+ @users("inventory", "admin")
+ def test_inventory_user_apply_inventory(self):
+ """Test that the inventory user can apply inventory"""
+ quant = (
+ self.env["stock.quant"]
+ .with_context(inventory_mode=True)
+ .create(
+ {
+ "product_id": self.product.id,
+ "inventory_quantity": 10,
+ "lot_id": self.product_lot.id,
+ "location_id": self.stock_location.id,
+ }
+ )
+ )
+ quant.action_apply_inventory()
+ self.assertEqual(self.product.qty_available, 10)
+
+ @users("stock")
+ def test_stock_user_apply_inventory(self):
+ """Test that the stock user cannot apply inventory"""
+ with self.assertRaisesRegex(
+ UserError, "Only a stock manager can validate an inventory adjustment."
+ ):
+ quant = self.env["stock.quant"].create(
+ {
+ "product_id": self.product.id,
+ "inventory_quantity": 10,
+ "lot_id": self.product_lot.id,
+ "location_id": self.stock_location.id,
+ }
+ )
+ quant.action_apply_inventory()
+
+ @users("inventory", "admin")
+ def test_inventory_user_apply_inventory_reason(self):
+ """Test that the inventory user can apply inventory with a reason"""
+ quant = (
+ self.env["stock.quant"]
+ .with_context(inventory_mode=True)
+ .create(
+ {
+ "product_id": self.product.id,
+ "lot_id": self.product_lot.id,
+ "location_id": self.stock_location.id,
+ "inventory_quantity": 10,
+ }
+ )
+ )
+ form_wizard = Form(
+ self.env["stock.inventory.adjustment.name"].with_context(
+ default_quant_ids=quant.ids
+ )
+ )
+ form_wizard.inventory_adjustment_name = "Inventory Adjustment - Test"
+ form_wizard.save().action_apply()
+ self.assertTrue(
+ self.env["stock.move"].search(
+ [("reference", "=", "Inventory Adjustment - Test")], limit=1
+ )
+ )
+ self.assertEqual(self.product.qty_available, 10)
diff --git a/stock_inventory_security/views/product.xml b/stock_inventory_security/views/product.xml
new file mode 100644
index 000000000000..455a26c50e87
--- /dev/null
+++ b/stock_inventory_security/views/product.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+ product.product
+
+
+
+
+
+
+
+ product.product
+
+
+
+
+
+
+
+ product.template
+
+
+
+
+
+
+
diff --git a/stock_inventory_security/views/stock_quant.xml b/stock_inventory_security/views/stock_quant.xml
new file mode 100644
index 000000000000..92fb1562f7d4
--- /dev/null
+++ b/stock_inventory_security/views/stock_quant.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+ stock.quant
+
+
+
+
+
+
+
+