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_discrepancy/__init__.py b/stock_inventory_discrepancy/__init__.py
index 0ceaa3daff2a..e1e144406194 100644
--- a/stock_inventory_discrepancy/__init__.py
+++ b/stock_inventory_discrepancy/__init__.py
@@ -2,4 +2,3 @@
from . import models
from . import wizards
-from .hooks import post_load_hook
diff --git a/stock_inventory_discrepancy/__manifest__.py b/stock_inventory_discrepancy/__manifest__.py
index c3fa9c8c26bf..b1d6eef925cf 100644
--- a/stock_inventory_discrepancy/__manifest__.py
+++ b/stock_inventory_discrepancy/__manifest__.py
@@ -20,7 +20,6 @@
"wizards/confirm_discrepancy_wiz.xml",
],
"license": "AGPL-3",
- "post_load": "post_load_hook",
"installable": True,
"application": False,
}
diff --git a/stock_inventory_discrepancy/hooks.py b/stock_inventory_discrepancy/hooks.py
deleted file mode 100644
index 84a1de372699..000000000000
--- a/stock_inventory_discrepancy/hooks.py
+++ /dev/null
@@ -1,76 +0,0 @@
-# Copyright 2019 ForgeFlow S.L.
-# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
-
-
-from odoo import _, fields
-from odoo.exceptions import UserError
-from odoo.tools.float_utils import float_compare
-
-from odoo.addons.stock.models.stock_quant import StockQuant
-
-
-def post_load_hook():
- def _apply_inventory_discrepancy(self):
- """Override method to avoid inline group validation"""
- move_vals = []
- # START HOOK: - Allow specific group to validate inventory
- # - Allow validate on pending status
- if (
- not self.user_has_groups("stock.group_stock_manager")
- and not self.user_has_groups(
- "stock_inventory_discrepancy.group_stock_inventory_validation"
- )
- and not self.user_has_groups(
- "stock_inventory_discrepancy.group_stock_inventory_validation_always"
- )
- ):
- raise UserError(
- _("Only a stock manager can validate an inventory adjustment.")
- )
- # Allow to write last_inventory_date on stock.location
- self = self.sudo()
- # END HOOK
- for quant in self:
- # Create and validate a move so that the quant matches its `inventory_quantity`.
- if (
- float_compare(
- quant.inventory_diff_quantity,
- 0,
- precision_rounding=quant.product_uom_id.rounding,
- )
- > 0
- ):
- move_vals.append(
- quant._get_inventory_move_values(
- quant.inventory_diff_quantity,
- quant.product_id.with_company(
- quant.company_id
- ).property_stock_inventory,
- quant.location_id,
- )
- )
- else:
- move_vals.append(
- quant._get_inventory_move_values(
- -quant.inventory_diff_quantity,
- quant.location_id,
- quant.product_id.with_company(
- quant.company_id
- ).property_stock_inventory,
- out=True,
- )
- )
- moves = (
- self.env["stock.move"].with_context(inventory_mode=False).create(move_vals)
- )
- moves._action_done()
- self.location_id.write({"last_inventory_date": fields.Date.today()})
- date_by_location = {
- loc: loc._get_next_inventory_date() for loc in self.mapped("location_id")
- }
- for quant in self:
- quant.inventory_date = date_by_location[quant.location_id]
- self.write({"inventory_quantity": 0, "user_id": False})
- self.write({"inventory_diff_quantity": 0})
-
- StockQuant._patch_method("_apply_inventory", _apply_inventory_discrepancy)
diff --git a/stock_inventory_discrepancy/models/stock_location.py b/stock_inventory_discrepancy/models/stock_location.py
index 96f927673d93..179c38939303 100644
--- a/stock_inventory_discrepancy/models/stock_location.py
+++ b/stock_inventory_discrepancy/models/stock_location.py
@@ -21,6 +21,17 @@ class StockLocation(models.Model):
)
def write(self, values):
+ # 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_discrepancy")
+ and len(values) == 1
+ and "last_inventory_date" in values
+ and self.user_has_groups(
+ "stock_inventory_discrepancy.group_stock_inventory_validation"
+ )
+ ):
+ self = self.sudo()
res = super().write(values)
# Set the discrepancy threshold for all child locations
if values.get("discrepancy_threshold", False):
diff --git a/stock_inventory_discrepancy/models/stock_quant.py b/stock_inventory_discrepancy/models/stock_quant.py
index 2674848b3e1d..1f8316f37254 100644
--- a/stock_inventory_discrepancy/models/stock_quant.py
+++ b/stock_inventory_discrepancy/models/stock_quant.py
@@ -73,6 +73,23 @@ def _compute_has_over_discrepancy(self):
rec.discrepancy_percent > rec.discrepancy_threshold
)
+ 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_discrepancy"
+ ):
+ groups = "stock_inventory_discrepancy.group_stock_inventory_validation"
+ return super().user_has_groups(groups)
+
+ def _apply_inventory(self):
+ if self.user_has_groups(
+ "stock_inventory_discrepancy.group_stock_inventory_validation"
+ ):
+ self = self.with_context(_stock_inventory_discrepancy=True)
+ return super()._apply_inventory()
+
def action_apply_inventory(self):
if self.env.context.get("skip_exceeded_discrepancy", False):
return super().action_apply_inventory()
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
+
+
+
+
+
+
+
+