[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.
This commit is contained in:
Marc Durepos 2025-09-03 14:20:09 -04:00
parent 3a4fc5762c
commit 8c8cb76c3f
9 changed files with 508 additions and 0 deletions

View file

@ -0,0 +1,2 @@
from . import wizards
from . import tests

View file

@ -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,
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Server Action for Stock Quant Reserved Quantity Fix -->
<record id="action_server_stock_quant_reserved_fix" model="ir.actions.server">
<field name="name">Fix Reserved Quantity</field>
<field name="model_id" ref="stock.model_stock_quant"/>
<field name="binding_model_id" ref="stock.model_stock_quant"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="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',
}
}
</field>
<field name="groups_id" eval="[(4, ref('stock.group_stock_manager'))]"/>
</record>
</data>
</odoo>

View file

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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

View file

@ -0,0 +1 @@
from . import test_stock_quant_reserved_fix_wizard

View file

@ -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)

View file

@ -0,0 +1 @@
from . import stock_quant_reserved_fix_wizard

View file

@ -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,
},
}

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Wizard Form View -->
<record id="view_stock_quant_reserved_fix_wizard_form" model="ir.ui.view">
<field name="name">stock.quant.reserved.fix.wizard.form</field>
<field name="model">stock.quant.reserved.fix.wizard</field>
<field name="arch" type="xml">
<form string="Fix Reserved Quantity">
<div class="alert alert-info" role="alert">
<p><strong>Warning:</strong> This action will directly modify the reserved quantity of the selected stock quants.</p>
<p>Make sure you understand the implications before proceeding.</p>
</div>
<group>
<group>
<field name="quant_count" readonly="1"/>
<field name="reserved_quantity" widget="float" />
</group>
</group>
<group string="Selected Stock Quants" invisible="quant_count == 0">
<field name="quant_ids" nolabel="1" readonly="1">
<tree>
<field name="product_id"/>
<field name="location_id"/>
<field name="lot_id"/>
<field name="package_id"/>
<field name="owner_id"/>
<field name="quantity"/>
<field name="reserved_quantity"/>
<field name="available_quantity"/>
</tree>
</field>
</group>
<footer>
<button name="action_fix_reserved_quantity"
string="Update Reserved Quantity"
type="object"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Wizard Action -->
<record id="action_stock_quant_reserved_fix_wizard" model="ir.actions.act_window">
<field name="name">Fix Reserved Quantity</field>
<field name="res_model">stock.quant.reserved.fix.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="stock.model_stock_quant"/>
<field name="binding_view_types">list</field>
</record>
</data>
</odoo>