[MIG] stock_quant_reserved_fix migrated to 18.0
This commit is contained in:
parent
7b8255abe2
commit
59fa4703ab
9 changed files with 512 additions and 0 deletions
2
stock_quant_reserved_fix/__init__.py
Normal file
2
stock_quant_reserved_fix/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import wizards
|
||||
from . import tests
|
||||
43
stock_quant_reserved_fix/__manifest__.py
Normal file
43
stock_quant_reserved_fix/__manifest__.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "Stock Quant Reserved Quantity Fix",
|
||||
"version": "18.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,
|
||||
}
|
||||
29
stock_quant_reserved_fix/data/ir_actions_server.xml
Normal file
29
stock_quant_reserved_fix/data/ir_actions_server.xml
Normal 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>
|
||||
2
stock_quant_reserved_fix/security/ir.model.access.csv
Normal file
2
stock_quant_reserved_fix/security/ir.model.access.csv
Normal 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
stock_quant_reserved_fix/tests/__init__.py
Normal file
1
stock_quant_reserved_fix/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_stock_quant_reserved_fix_wizard
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
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": "consu",
|
||||
"is_storable": True,
|
||||
"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)
|
||||
1
stock_quant_reserved_fix/wizards/__init__.py
Normal file
1
stock_quant_reserved_fix/wizards/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import stock_quant_reserved_fix_wizard
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
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)
|
||||
|
||||
# Close the wizard and show success notification
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "display_notification",
|
||||
"params": {
|
||||
"title": _("Success"),
|
||||
"message": message,
|
||||
"type": "success",
|
||||
"sticky": False,
|
||||
"next": {"type": "ir.actions.act_window_close"},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?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>
|
||||
|
||||
<notebook>
|
||||
<page string="Selected Quants">
|
||||
<field name="quant_ids" nolabel="1" readonly="1">
|
||||
<list>
|
||||
<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"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
<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>
|
||||
Loading…
Reference in a new issue