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 + +
+