[bug] support custom stock status on tranfer (#13194)

- Closes https://github.com/inventree/InvenTree/issues/13191
This commit is contained in:
Oliver authored and GitHub committed 2026-11-05 14:16:51 +11:00
1 parent d01645c5fb
commit a6b7a5bf3a
2 files changed
+175 -15

No files matched your search

+17 -15
View File
@@ -977,10 +977,23 @@ class UninstallStockItemSerializer(serializers.Serializer):
class StockStatusCustomSerializer(serializers.ChoiceField):
"""Serializer to allow annotating the schema to use int where custom values may be entered."""
def __init__(self, *args, **kwargs):
"""Initialize the status selector."""
def __init__(self, *args, include_no_change: bool = False, **kwargs):
"""Initialize the status selector.
Arguments:
include_no_change: If True, prepend a null "No Change" option to the choices
Note: Choices are evaluated here (rather than being passed in) so that
custom states which are added at runtime are available. Declared serializer
fields are deep-copied (re-initialized) for each serializer instance.
"""
if 'choices' not in kwargs:
kwargs['choices'] = stock.status_codes.StockStatus.items(custom=True)
choices = list(stock.status_codes.StockStatus.items(custom=True))
if include_no_change:
choices = [(None, _('No Change')), *choices]
kwargs['choices'] = choices
if 'label' not in kwargs:
kwargs['label'] = _('Status')
@@ -1753,14 +1766,6 @@ class StockMergeSerializer(serializers.Serializer):
)
def stock_item_adjust_status_options():
"""Return a custom set of options for the StockItem status adjustment field.
In particular, include a Null option for the status field.
"""
return [(None, _('No Change')), *stock.status_codes.StockStatus.items(custom=True)]
class StockAdjustmentItemSerializer(serializers.Serializer):
"""Serializer for a single StockItem within a stock adjustment request.
@@ -1840,10 +1845,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
)
status = StockStatusCustomSerializer(
choices=stock_item_adjust_status_options(),
default=None,
required=False,
allow_blank=True,
include_no_change=True, default=None, required=False, allow_blank=True
)
packaging = serializers.CharField(
+158
View File
@@ -3403,6 +3403,164 @@ class StocktakeTest(StockAPITestCase):
self.assertEqual(len(response.data['items']), 100)
class StockAdjustmentCustomStatusTest(StockAPITestCase):
"""Tests for applying custom status values via the stock adjustment endpoints.
Regression tests for https://github.com/inventree/InvenTree/issues/13191
The available status choices for stock adjustment items were evaluated once
(at import time), so custom states created afterwards were rejected as invalid.
"""
def setUp(self):
"""Create a custom stock status *after* the serializer module has been imported."""
super().setUp()
self.custom_status = InvenTreeCustomUserStateModel.objects.create(
key=160,
name='Custom attention',
label='Custom attention',
color='warning',
logical_key=StockStatus.ATTENTION.value,
model=ContentType.objects.get(model='stockitem'),
reference_status='StockStatus',
)
self.part = Part.objects.get(pk=1)
self.source = StockLocation.objects.get(pk=5)
self.dest = StockLocation.objects.get(pk=2)
def test_serializer_choices(self):
"""The adjustment item serializer must offer the runtime custom state."""
from stock.serializers import StockAdjustmentItemSerializer
choices = StockAdjustmentItemSerializer().fields['status'].choices
self.assertIn(None, choices)
self.assertIn(StockStatus.OK.value, choices)
self.assertIn(self.custom_status.key, choices)
# A state created later must also be picked up by a new serializer instance
later_status = InvenTreeCustomUserStateModel.objects.create(
key=170,
name='Custom later',
label='Custom later',
color='info',
logical_key=StockStatus.OK.value,
model=ContentType.objects.get(model='stockitem'),
reference_status='StockStatus',
)
choices = StockAdjustmentItemSerializer().fields['status'].choices
self.assertIn(later_status.key, choices)
def test_adjust_with_custom_status(self):
"""Each stock adjustment endpoint must accept a custom status."""
# Note: transfer the full quantity, so that the original item is moved
for endpoint, quantity, extra in [
('api-stock-transfer', 10, {'location': self.dest.pk}),
('api-stock-count', 5, {}),
('api-stock-add', 5, {}),
('api-stock-remove', 5, {}),
]:
with self.subTest(endpoint=endpoint):
item = StockItem.objects.create(
part=self.part, location=self.source, quantity=10
)
self.assertEqual(item.status, StockStatus.OK.value)
self.post(
reverse(endpoint),
{
'items': [
{
'pk': item.pk,
'quantity': quantity,
'status': self.custom_status.key,
}
],
'notes': 'Custom status adjustment',
**extra,
},
expected_code=201,
)
item.refresh_from_db()
self.assertEqual(item.status_custom_key, self.custom_status.key)
self.assertEqual(item.status, StockStatus.ATTENTION.value)
def test_partial_transfer_with_custom_status(self):
"""A partial transfer applies the custom status to the split item only."""
item = StockItem.objects.create(
part=self.part, location=self.source, quantity=10
)
self.post(
reverse('api-stock-transfer'),
{
'items': [
{'pk': item.pk, 'quantity': 4, 'status': self.custom_status.key}
],
'location': self.dest.pk,
},
expected_code=201,
)
item.refresh_from_db()
self.assertEqual(item.quantity, 6)
self.assertEqual(item.location, self.source)
self.assertEqual(item.status, StockStatus.OK.value)
self.assertIsNone(item.status_custom_key)
new_item = StockItem.objects.get(
part=self.part, location=self.dest, parent=item
)
self.assertEqual(new_item.quantity, 4)
self.assertEqual(new_item.location, self.dest)
self.assertEqual(new_item.status_custom_key, self.custom_status.key)
self.assertEqual(new_item.status, StockStatus.ATTENTION.value)
def test_invalid_status_rejected(self):
"""Unknown status values must still be rejected."""
item = StockItem.objects.create(
part=self.part, location=self.source, quantity=10
)
response = self.post(
reverse('api-stock-transfer'),
{
'items': [{'pk': item.pk, 'quantity': 5, 'status': 999}],
'location': self.dest.pk,
},
expected_code=400,
)
self.assertIn('is not a valid choice', str(response.data))
item.refresh_from_db()
self.assertEqual(item.location, self.source)
self.assertEqual(item.quantity, 10)
def test_no_status_change(self):
"""Omitting the status (or passing a blank value) leaves the status unchanged."""
item = StockItem.objects.create(
part=self.part, location=self.source, quantity=10
)
item.set_status(self.custom_status.key)
item.save()
for idx, line in enumerate([{}, {'status': ''}]):
with self.subTest(line=line):
self.post(
reverse('api-stock-count'),
{'items': [{'pk': item.pk, 'quantity': 8 - idx, **line}]},
expected_code=201,
)
item.refresh_from_db()
self.assertEqual(item.status_custom_key, self.custom_status.key)
class StockTransferMergeTest(StockAPITestCase):
"""Tests for optional merge-on-transfer behavior."""