From 8c8cb76c3f432cfa4c29c899a60dbe49a5a23507 Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Wed, 3 Sep 2025 14:20:09 -0400 Subject: [PATCH] [ADD] New module stock_quant_reserved_fix (admin powers) Adds a new module to allow admins to fix the stock quant reserved quantity manually from the Physical Inventories list (or other stock.quant list views). This is necessary because the reserved quantity sometimes goes out of whack with stock transfers cancelling or otherwise changing but not updating the stock quant to unreserve the quantities they had previously reserved. This is, of course, less ideal than fixing the issue at its core, but it helps to have a tool for intervention when necessary. --- stock_quant_reserved_fix/__init__.py | 2 + stock_quant_reserved_fix/__manifest__.py | 43 +++ .../data/ir_actions_server.xml | 29 ++ .../security/ir.model.access.csv | 2 + stock_quant_reserved_fix/tests/__init__.py | 1 + .../test_stock_quant_reserved_fix_wizard.py | 275 ++++++++++++++++++ stock_quant_reserved_fix/wizards/__init__.py | 1 + .../stock_quant_reserved_fix_wizard.py | 97 ++++++ .../stock_quant_reserved_fix_wizard_views.xml | 58 ++++ 9 files changed, 508 insertions(+) create mode 100644 stock_quant_reserved_fix/__init__.py create mode 100644 stock_quant_reserved_fix/__manifest__.py create mode 100644 stock_quant_reserved_fix/data/ir_actions_server.xml create mode 100644 stock_quant_reserved_fix/security/ir.model.access.csv create mode 100644 stock_quant_reserved_fix/tests/__init__.py create mode 100644 stock_quant_reserved_fix/tests/test_stock_quant_reserved_fix_wizard.py create mode 100644 stock_quant_reserved_fix/wizards/__init__.py create mode 100644 stock_quant_reserved_fix/wizards/stock_quant_reserved_fix_wizard.py create mode 100644 stock_quant_reserved_fix/wizards/stock_quant_reserved_fix_wizard_views.xml diff --git a/stock_quant_reserved_fix/__init__.py b/stock_quant_reserved_fix/__init__.py new file mode 100644 index 0000000..52f2c76 --- /dev/null +++ b/stock_quant_reserved_fix/__init__.py @@ -0,0 +1,2 @@ +from . import wizards +from . import tests diff --git a/stock_quant_reserved_fix/__manifest__.py b/stock_quant_reserved_fix/__manifest__.py new file mode 100644 index 0000000..c31db8d --- /dev/null +++ b/stock_quant_reserved_fix/__manifest__.py @@ -0,0 +1,43 @@ +{ + 'name': 'Stock Quant Reserved Quantity Fix', + 'version': '17.0.1.0.0', + 'category': 'Inventory/Inventory', + 'summary': 'Admin tool to manually correct stock quant reserved quantities', + 'description': """ +Stock Quant Reserved Quantity Fix +================================= + +This module provides a quick fix for situations where stock quants get their +reserved quantities corrupted when stock operations are cancelled. + +Features: +--------- +* Server action on stock.quant records for admin users +* Wizard interface to manually set reserved_quantity values +* Easy access through the stock quant list view +* Automatic refresh of current search after update + +Usage: +------ +1. Navigate to Inventory > Configuration > Locations > Stock Quants +2. Select one or more stock quant records +3. Use the "Fix Reserved Quantity" action from the Action menu +4. Enter the correct reserved quantity value in the wizard +5. Confirm to apply the changes + +This module is intended as a temporary fix while investigating the root cause +of the reserved quantity corruption issue in Odoo's core stock operations. + """, + 'author': 'Bemade Inc.', + 'website': 'https://www.bemade.org', + 'license': 'LGPL-3', + 'depends': ['stock'], + 'data': [ + 'security/ir.model.access.csv', + 'wizards/stock_quant_reserved_fix_wizard_views.xml', + 'data/ir_actions_server.xml', + ], + 'installable': True, + 'auto_install': False, + 'application': False, +} diff --git a/stock_quant_reserved_fix/data/ir_actions_server.xml b/stock_quant_reserved_fix/data/ir_actions_server.xml new file mode 100644 index 0000000..42e4a26 --- /dev/null +++ b/stock_quant_reserved_fix/data/ir_actions_server.xml @@ -0,0 +1,29 @@ + + + + + + Fix Reserved Quantity + + + list + code + +# Open the wizard with selected stock quants +action = { + 'type': 'ir.actions.act_window', + 'name': 'Fix Reserved Quantity', + 'res_model': 'stock.quant.reserved.fix.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_quant_ids': [(6, 0, env.context.get('active_ids', []))], + 'active_ids': env.context.get('active_ids', []), + 'active_model': 'stock.quant', + } +} + + + + + diff --git a/stock_quant_reserved_fix/security/ir.model.access.csv b/stock_quant_reserved_fix/security/ir.model.access.csv new file mode 100644 index 0000000..00f2d75 --- /dev/null +++ b/stock_quant_reserved_fix/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_quant_reserved_fix_wizard_stock_manager,stock.quant.reserved.fix.wizard stock_manager,model_stock_quant_reserved_fix_wizard,stock.group_stock_manager,1,1,1,1 diff --git a/stock_quant_reserved_fix/tests/__init__.py b/stock_quant_reserved_fix/tests/__init__.py new file mode 100644 index 0000000..09ec9f6 --- /dev/null +++ b/stock_quant_reserved_fix/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_quant_reserved_fix_wizard diff --git a/stock_quant_reserved_fix/tests/test_stock_quant_reserved_fix_wizard.py b/stock_quant_reserved_fix/tests/test_stock_quant_reserved_fix_wizard.py new file mode 100644 index 0000000..f4a6a87 --- /dev/null +++ b/stock_quant_reserved_fix/tests/test_stock_quant_reserved_fix_wizard.py @@ -0,0 +1,275 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError, AccessError +from odoo import fields + + +class TestStockQuantReservedFixWizard(TransactionCase): + + def setUp(self): + super().setUp() + + # Create test data + self.product = self.env["product.product"].create( + { + "name": "Test Product", + "type": "product", + "tracking": "none", + } + ) + + self.location = self.env["stock.location"].create( + { + "name": "Test Location", + "usage": "internal", + } + ) + + # Create stock quants for testing + self.quant1 = self.env["stock.quant"].create( + { + "product_id": self.product.id, + "location_id": self.location.id, + "quantity": 100.0, + "reserved_quantity": 10.0, + } + ) + + self.quant2 = self.env["stock.quant"].create( + { + "product_id": self.product.id, + "location_id": self.location.id, + "quantity": 50.0, + "reserved_quantity": 5.0, + } + ) + + # Create stock manager user for testing + self.stock_manager = self.env["res.users"].create( + { + "name": "Test Stock Manager", + "login": "test_stock_manager", + "email": "test@example.com", + "groups_id": [(6, 0, [self.env.ref("stock.group_stock_manager").id])], + } + ) + + # Create regular user without stock manager rights + self.regular_user = self.env["res.users"].create( + { + "name": "Test Regular User", + "login": "test_regular_user", + "email": "regular@example.com", + "groups_id": [(6, 0, [self.env.ref("base.group_user").id])], + } + ) + + def test_wizard_default_get_single_quant(self): + """Test wizard default_get with single quant""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_context(active_ids=[self.quant1.id], active_model="stock.quant") + .create({}) + ) + + self.assertEqual(len(wizard.quant_ids), 1) + self.assertEqual(wizard.quant_ids[0], self.quant1) + self.assertEqual(wizard.quant_count, 1) + + def test_wizard_default_get_multiple_quants(self): + """Test wizard default_get with multiple quants""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_context( + active_ids=[self.quant1.id, self.quant2.id], active_model="stock.quant" + ) + .create({}) + ) + + self.assertEqual(len(wizard.quant_ids), 2) + self.assertIn(self.quant1, wizard.quant_ids) + self.assertIn(self.quant2, wizard.quant_ids) + self.assertEqual(wizard.quant_count, 2) + + def test_successful_reserved_quantity_update(self): + """Test successful update of reserved quantity""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_user(self.stock_manager) + .create( + { + "quant_ids": [(6, 0, [self.quant1.id])], + "reserved_quantity": 20.0, + } + ) + ) + + # Execute the action + result = wizard.action_fix_reserved_quantity() + + # Check that reserved quantity was updated + self.assertEqual(self.quant1.reserved_quantity, 20.0) + + # Check that success notification is returned + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + self.assertEqual(result["params"]["type"], "success") + + def test_multiple_quants_update(self): + """Test updating multiple quants at once""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_user(self.stock_manager) + .create( + { + "quant_ids": [(6, 0, [self.quant1.id, self.quant2.id])], + "reserved_quantity": 15.0, + } + ) + ) + + # Execute the action + wizard.action_fix_reserved_quantity() + + # Check that both quants were updated + self.assertEqual(self.quant1.reserved_quantity, 15.0) + self.assertEqual(self.quant2.reserved_quantity, 15.0) + + def test_validation_no_quants_selected(self): + """Test validation when no quants are selected""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_user(self.stock_manager) + .create( + { + "quant_ids": [(6, 0, [])], + "reserved_quantity": 10.0, + } + ) + ) + + with self.assertRaises(UserError) as cm: + wizard.action_fix_reserved_quantity() + + self.assertIn("No stock quants selected", str(cm.exception)) + + def test_validation_negative_reserved_quantity(self): + """Test validation for negative reserved quantity""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_user(self.stock_manager) + .create( + { + "quant_ids": [(6, 0, [self.quant1.id])], + "reserved_quantity": -5.0, + } + ) + ) + + with self.assertRaises(UserError) as cm: + wizard.action_fix_reserved_quantity() + + self.assertIn("Reserved quantity cannot be negative", str(cm.exception)) + + def test_validation_reserved_exceeds_available(self): + """Test validation when reserved quantity exceeds available quantity""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_user(self.stock_manager) + .create( + { + "quant_ids": [(6, 0, [self.quant1.id])], + "reserved_quantity": 150.0, # More than the 100.0 available + } + ) + ) + + with self.assertRaises(UserError) as cm: + wizard.action_fix_reserved_quantity() + + self.assertIn( + "Reserved quantity (150.00) cannot exceed available quantity (100.00)", + str(cm.exception), + ) + + def test_security_stock_manager_access(self): + """Test that stock managers can access the wizard""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_user(self.stock_manager) + .create( + { + "quant_ids": [(6, 0, [self.quant1.id])], + "reserved_quantity": 20.0, + } + ) + ) + + # This should not raise an exception + result = wizard.action_fix_reserved_quantity() + self.assertEqual(result["type"], "ir.actions.client") + + def test_security_regular_user_denied(self): + """Test that regular users cannot perform the action""" + with self.assertRaises(AccessError): + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_user(self.regular_user) + .create( + { + "quant_ids": [(6, 0, [self.quant1.id])], + "reserved_quantity": 20.0, + } + ) + ) + wizard.action_fix_reserved_quantity() + + def test_zero_reserved_quantity(self): + """Test setting reserved quantity to zero""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_user(self.stock_manager) + .create( + { + "quant_ids": [(6, 0, [self.quant1.id])], + "reserved_quantity": 0.0, + } + ) + ) + + wizard.action_fix_reserved_quantity() + self.assertEqual(self.quant1.reserved_quantity, 0.0) + + def test_reserved_quantity_equals_available(self): + """Test setting reserved quantity equal to available quantity""" + wizard = ( + self.env["stock.quant.reserved.fix.wizard"] + .with_user(self.stock_manager) + .create( + { + "quant_ids": [(6, 0, [self.quant1.id])], + "reserved_quantity": 100.0, # Equal to available quantity + } + ) + ) + + wizard.action_fix_reserved_quantity() + self.assertEqual(self.quant1.reserved_quantity, 100.0) + + def test_quant_count_computation(self): + """Test quant_count field computation""" + wizard = self.env["stock.quant.reserved.fix.wizard"].create( + { + "quant_ids": [(6, 0, [self.quant1.id, self.quant2.id])], + "reserved_quantity": 0, + } + ) + + self.assertEqual(wizard.quant_count, 2) + + # Remove one quant + wizard.quant_ids = [(6, 0, [self.quant1.id])] + self.assertEqual(wizard.quant_count, 1) + + # Remove all quants + wizard.quant_ids = [(6, 0, [])] + self.assertEqual(wizard.quant_count, 0) diff --git a/stock_quant_reserved_fix/wizards/__init__.py b/stock_quant_reserved_fix/wizards/__init__.py new file mode 100644 index 0000000..93ea55f --- /dev/null +++ b/stock_quant_reserved_fix/wizards/__init__.py @@ -0,0 +1 @@ +from . import stock_quant_reserved_fix_wizard diff --git a/stock_quant_reserved_fix/wizards/stock_quant_reserved_fix_wizard.py b/stock_quant_reserved_fix/wizards/stock_quant_reserved_fix_wizard.py new file mode 100644 index 0000000..f446fac --- /dev/null +++ b/stock_quant_reserved_fix/wizards/stock_quant_reserved_fix_wizard.py @@ -0,0 +1,97 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class StockQuantReservedFixWizard(models.TransientModel): + _name = "stock.quant.reserved.fix.wizard" + _description = "Stock Quant Reserved Quantity Fix Wizard" + + quant_ids = fields.Many2many( + "stock.quant", + string="Stock Quants", + required=True, + help="Stock quants to update reserved quantity for", + ) + + reserved_quantity = fields.Float( + string="New Reserved Quantity", + digits="Product Unit of Measure", + required=True, + help="The new reserved quantity to set for the selected stock quants", + ) + + quant_count = fields.Integer( + string="Number of Quants", + compute="_compute_quant_count", + help="Number of selected stock quants", + ) + + @api.depends("quant_ids") + def _compute_quant_count(self): + for wizard in self: + wizard.quant_count = len(wizard.quant_ids) + + @api.model + def default_get(self, fields_list): + """Get default values from context""" + res = super().default_get(fields_list) + + # Get active stock quant records from context + active_ids = self.env.context.get("active_ids", []) + active_model = self.env.context.get("active_model") + + if active_model == "stock.quant" and active_ids: + res["quant_ids"] = [(6, 0, active_ids)] + res["reserved_quantity"] = 0 + + return res + + def action_fix_reserved_quantity(self): + """Update the reserved quantity for selected stock quants""" + if not self.quant_ids: + raise UserError(_("No stock quants selected.")) + + if self.reserved_quantity < 0: + raise UserError(_("Reserved quantity cannot be negative.")) + + # Check if user has write access to stock.quant + if not self.env.user.has_group("stock.group_stock_manager"): + raise UserError(_("You need to be a Stock Manager to perform this action.")) + + # Update reserved quantity for all selected quants + updated_count = 0 + for quant in self.quant_ids: + # Validate that the reserved quantity doesn't exceed available quantity + if self.reserved_quantity > quant.quantity: + raise UserError( + _( + "Reserved quantity (%.2f) cannot exceed available quantity (%.2f) " + "for product %s in location %s." + ) + % ( + self.reserved_quantity, + quant.quantity, + quant.product_id.display_name, + quant.location_id.display_name, + ) + ) + + # Update the reserved quantity + quant.sudo().write({"reserved_quantity": self.reserved_quantity}) + updated_count += 1 + + # Show success message + message = _( + "Successfully updated reserved quantity to %.2f for %d stock quant(s)." + ) % (self.reserved_quantity, updated_count) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": message, + "type": "success", + "sticky": False, + }, + } diff --git a/stock_quant_reserved_fix/wizards/stock_quant_reserved_fix_wizard_views.xml b/stock_quant_reserved_fix/wizards/stock_quant_reserved_fix_wizard_views.xml new file mode 100644 index 0000000..13046af --- /dev/null +++ b/stock_quant_reserved_fix/wizards/stock_quant_reserved_fix_wizard_views.xml @@ -0,0 +1,58 @@ + + + + + + stock.quant.reserved.fix.wizard.form + stock.quant.reserved.fix.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + + Fix Reserved Quantity + stock.quant.reserved.fix.wizard + form + new + + list + +
+