mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-17 01:38:19 +00:00
[refactor] Stock return API endpoints (#10132)
* Add "StockReturn" API endpoint - Provide multiple items - Provide quantity for each item * Add frontend form * update frontend forms * Refactor frontend * Allow splitting quantity * Refactoring backend endpoints * cleanup * Update unit test * unit tests * Bump API version * Fix unit test * Add tests for returning build items to stock * Playwright tests * Enhanced unit tests * Add docs
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 380
|
||||
INVENTREE_API_VERSION = 381
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v381 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10132
|
||||
- Refactor the "return stock item" API endpoint to align with other stock adjustment actions
|
||||
|
||||
v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10135
|
||||
- Fixes "issued_by" filter for the BuildOrder list API endpoint
|
||||
|
||||
v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10132
|
||||
- Refactor the "return stock item" API endpoint to align with other stock adjustment actions
|
||||
|
||||
v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10135
|
||||
- Fixes "issued_by" filter for the BuildOrder list API endpoint
|
||||
|
||||
|
||||
@@ -671,10 +671,14 @@ class Build(
|
||||
get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS')
|
||||
and self.has_open_child_builds
|
||||
):
|
||||
return
|
||||
raise ValidationError(
|
||||
_('Cannot complete build order with open child builds')
|
||||
)
|
||||
|
||||
if self.incomplete_count > 0:
|
||||
return
|
||||
raise ValidationError(
|
||||
_('Cannot complete build order with incomplete outputs')
|
||||
)
|
||||
|
||||
if trim_allocated_stock:
|
||||
self.trim_allocated_stock()
|
||||
|
||||
@@ -50,11 +50,11 @@ def complete_build_allocations(build_id: int, user_id: int):
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
user = None
|
||||
logger.warning(
|
||||
'Could not complete build allocations for BuildOrder <%s> - User does not exist',
|
||||
build_id,
|
||||
)
|
||||
return
|
||||
else:
|
||||
user = None
|
||||
|
||||
|
||||
@@ -498,9 +498,44 @@ class BuildTest(BuildTestBase):
|
||||
self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
|
||||
|
||||
# Check that the "consumed_by" item count has increased
|
||||
self.assertEqual(
|
||||
StockItem.objects.filter(consumed_by=self.build).count(), n + 8
|
||||
)
|
||||
consumed_items = StockItem.objects.filter(consumed_by=self.build)
|
||||
self.assertEqual(consumed_items.count(), n + 8)
|
||||
|
||||
# Finally, return the items into stock
|
||||
location = StockLocation.objects.filter(structural=False).first()
|
||||
|
||||
for item in consumed_items:
|
||||
item.return_to_stock(location)
|
||||
|
||||
# No consumed items should remain
|
||||
self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), 0)
|
||||
|
||||
def test_return_consumed(self):
|
||||
"""Test returning consumed stock items to stock."""
|
||||
self.build.auto_allocate_stock(interchangeable=True)
|
||||
|
||||
self.build.incomplete_outputs.delete()
|
||||
|
||||
self.assertGreater(self.build.allocated_stock.count(), 0)
|
||||
|
||||
self.build.complete_build(self.user)
|
||||
consumed_items = StockItem.objects.filter(consumed_by=self.build)
|
||||
self.assertGreater(consumed_items.count(), 0)
|
||||
|
||||
location = StockLocation.objects.filter(structural=False).last()
|
||||
|
||||
# Return a partial quantity of each item to stock
|
||||
for item in consumed_items:
|
||||
self.assertEqual(item.get_descendant_count(), 0)
|
||||
q = item.quantity
|
||||
self.assertGreater(item.quantity, 1)
|
||||
item.return_to_stock(location, merge=False, quantity=1)
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.quantity, q - 1)
|
||||
self.assertEqual(item.get_descendant_count(), 1)
|
||||
self.assertFalse(item.is_in_stock())
|
||||
child = item.get_descendants().first()
|
||||
self.assertTrue(child.is_in_stock())
|
||||
|
||||
def test_change_part(self):
|
||||
"""Try to change target part after creating a build."""
|
||||
|
||||
@@ -161,12 +161,6 @@ class StockItemConvert(StockItemContextMixin, CreateAPI):
|
||||
serializer_class = StockSerializers.ConvertStockItemSerializer
|
||||
|
||||
|
||||
class StockItemReturn(StockItemContextMixin, CreateAPI):
|
||||
"""API endpoint for returning a stock item from a customer."""
|
||||
|
||||
serializer_class = StockSerializers.ReturnStockItemSerializer
|
||||
|
||||
|
||||
class StockAdjustView(CreateAPI):
|
||||
"""A generic class for handling stocktake actions.
|
||||
|
||||
@@ -218,6 +212,17 @@ class StockTransfer(StockAdjustView):
|
||||
serializer_class = StockSerializers.StockTransferSerializer
|
||||
|
||||
|
||||
class StockReturn(StockAdjustView):
|
||||
"""API endpoint for returning items into stock.
|
||||
|
||||
This API endpoint is for items that are initially considered "not in stock",
|
||||
and the user wants to return them to stock, marking them as
|
||||
"available" for further consumption or sale.
|
||||
"""
|
||||
|
||||
serializer_class = StockSerializers.StockReturnSerializer
|
||||
|
||||
|
||||
class StockAssign(CreateAPI):
|
||||
"""API endpoint for assigning stock to a particular customer."""
|
||||
|
||||
@@ -1600,6 +1605,7 @@ stock_api_urls = [
|
||||
path('add/', StockAdd.as_view(), name='api-stock-add'),
|
||||
path('remove/', StockRemove.as_view(), name='api-stock-remove'),
|
||||
path('transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
|
||||
path('return/', StockReturn.as_view(), name='api-stock-return'),
|
||||
path('assign/', StockAssign.as_view(), name='api-stock-assign'),
|
||||
path('merge/', StockMerge.as_view(), name='api-stock-merge'),
|
||||
path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'),
|
||||
@@ -1657,7 +1663,6 @@ stock_api_urls = [
|
||||
MetadataView.as_view(model=StockItem),
|
||||
name='api-stock-item-metadata',
|
||||
),
|
||||
path('return/', StockItemReturn.as_view(), name='api-stock-item-return'),
|
||||
path(
|
||||
'serialize/',
|
||||
StockItemSerialize.as_view(),
|
||||
|
||||
@@ -8,6 +8,7 @@ class StockEvents(BaseEventEnum):
|
||||
|
||||
# StockItem events
|
||||
ITEM_ASSIGNED_TO_CUSTOMER = 'stockitem.assignedtocustomer'
|
||||
ITEM_RETURNED_TO_STOCK = 'stockitem.returnedtostock'
|
||||
ITEM_RETURNED_FROM_CUSTOMER = 'stockitem.returnedfromcustomer'
|
||||
ITEM_SPLIT = 'stockitem.split'
|
||||
ITEM_MOVED = 'stockitem.moved'
|
||||
|
||||
@@ -1387,47 +1387,101 @@ class StockItem(
|
||||
|
||||
If the selected location is the same as the parent, merge stock back into the parent.
|
||||
Otherwise create the stock in the new location.
|
||||
|
||||
Note that this function is provided for legacy compatibility,
|
||||
and the 'return_to_stock' function should be used instead.
|
||||
"""
|
||||
self.return_to_stock(
|
||||
location,
|
||||
user,
|
||||
tracking_code=StockHistoryCode.RETURNED_FROM_CUSTOMER,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def return_to_stock(
|
||||
self, location, user=None, quantity=None, merge: bool = True, **kwargs
|
||||
):
|
||||
"""Return stock item into stock, removing any consumption status.
|
||||
|
||||
Arguments:
|
||||
location: The location to return the stock item to
|
||||
user: The user performing the action
|
||||
quantity: If specified, the quantity to return to stock (default is the full quantity)
|
||||
merge: If True, attempt to merge this stock item back into the parent stock item
|
||||
"""
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
tracking_info = {'location': location.pk}
|
||||
tracking_code = kwargs.get('tracking_code', StockHistoryCode.RETURNED_TO_STOCK)
|
||||
|
||||
if self.customer:
|
||||
tracking_info['customer'] = self.customer.id
|
||||
tracking_info['customer_name'] = self.customer.name
|
||||
item = self
|
||||
|
||||
if quantity is not None and not self.serialized:
|
||||
# If quantity is specified, we are splitting the stock item
|
||||
if quantity <= 0:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be greater than zero')
|
||||
})
|
||||
|
||||
if quantity > self.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity exceeds available stock')
|
||||
})
|
||||
|
||||
if quantity < self.quantity:
|
||||
# Split the stock item
|
||||
item = self.splitStock(quantity, None, user)
|
||||
|
||||
tracking_info = {}
|
||||
|
||||
if location:
|
||||
tracking_info['location'] = location.pk
|
||||
|
||||
if item.customer:
|
||||
tracking_info['customer'] = item.customer.id
|
||||
tracking_info['customer_name'] = item.customer.name
|
||||
|
||||
if item.consumed_by:
|
||||
tracking_info['build_order'] = item.consumed_by.id
|
||||
|
||||
# Clear out allocation information for the stock item
|
||||
self.customer = None
|
||||
self.belongs_to = None
|
||||
self.sales_order = None
|
||||
self.location = location
|
||||
item.consumed_by = None
|
||||
item.customer = None
|
||||
item.belongs_to = None
|
||||
item.sales_order = None
|
||||
item.location = location
|
||||
|
||||
if status := kwargs.pop('status', None):
|
||||
if not self.compare_status(status):
|
||||
self.set_status(status)
|
||||
if not item.compare_status(status):
|
||||
item.set_status(status)
|
||||
tracking_info['status'] = status
|
||||
|
||||
self.save()
|
||||
item.save()
|
||||
|
||||
self.clearAllocations()
|
||||
item.clearAllocations()
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.RETURNED_FROM_CUSTOMER,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas=tracking_info,
|
||||
location=location,
|
||||
item.add_tracking_entry(
|
||||
tracking_code, user, notes=notes, deltas=tracking_info, location=location
|
||||
)
|
||||
|
||||
trigger_event(StockEvents.ITEM_RETURNED_FROM_CUSTOMER, id=self.id)
|
||||
trigger_event(StockEvents.ITEM_RETURNED_TO_STOCK, id=item.id)
|
||||
|
||||
"""If new location is the same as the parent location, merge this stock back in the parent"""
|
||||
if self.parent and self.location == self.parent.location:
|
||||
# Attempt to merge returned item into parent item:
|
||||
# - 'merge' parameter is True
|
||||
# - The parent location is the same as the current location
|
||||
# - The item does not have a serial number
|
||||
|
||||
if (
|
||||
merge
|
||||
and not item.serialized
|
||||
and self.parent
|
||||
and item.location == self.parent.location
|
||||
):
|
||||
self.parent.merge_stock_items(
|
||||
{self}, user=user, location=location, notes=notes
|
||||
{item}, user=user, location=location, notes=notes
|
||||
)
|
||||
else:
|
||||
self.save(add_note=False)
|
||||
item.save(add_note=False)
|
||||
|
||||
def is_allocated(self):
|
||||
"""Return True if this StockItem is allocated to a SalesOrder or a Build."""
|
||||
|
||||
@@ -1020,53 +1020,6 @@ class StockStatusCustomSerializer(serializers.ChoiceField):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class ReturnStockItemSerializer(serializers.Serializer):
|
||||
"""DRF serializer for returning a stock item from a customer."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['location', 'status', 'notes']
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination location for returned item'),
|
||||
)
|
||||
|
||||
status = StockStatusCustomSerializer(default=None, required=False, allow_blank=True)
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
help_text=_('Add transaction note (optional)'),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to return the item into stock."""
|
||||
item = self.context.get('item')
|
||||
|
||||
if not item:
|
||||
raise ValidationError(_('No stock item provided'))
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
location = data['location']
|
||||
|
||||
item.return_from_customer(
|
||||
location,
|
||||
user=request.user,
|
||||
notes=data.get('notes', ''),
|
||||
status=data.get('status', None),
|
||||
)
|
||||
|
||||
|
||||
class StockChangeStatusSerializer(serializers.Serializer):
|
||||
"""Serializer for changing status of multiple StockItem objects."""
|
||||
|
||||
@@ -1612,6 +1565,15 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
|
||||
fields = ['pk', 'quantity', 'batch', 'status', 'packaging']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the serializer."""
|
||||
# Store the 'require_in_stock' status
|
||||
# Either True / False / None
|
||||
self.require_in_stock = kwargs.pop('require_in_stock', True)
|
||||
self.require_non_zero = kwargs.pop('require_non_zero', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
pk = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
many=False,
|
||||
@@ -1623,14 +1585,18 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
|
||||
def validate_pk(self, stock_item: StockItem) -> StockItem:
|
||||
"""Ensure the stock item is valid."""
|
||||
allow_out_of_stock_transfer = get_global_setting(
|
||||
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False
|
||||
)
|
||||
if self.require_in_stock == True:
|
||||
allow_out_of_stock_transfer = get_global_setting(
|
||||
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False
|
||||
)
|
||||
|
||||
if not allow_out_of_stock_transfer and not stock_item.is_in_stock(
|
||||
check_status=False, check_quantity=False
|
||||
):
|
||||
raise ValidationError(_('Stock item is not in stock'))
|
||||
if not allow_out_of_stock_transfer and not stock_item.is_in_stock(
|
||||
check_status=False, check_quantity=False
|
||||
):
|
||||
raise ValidationError(_('Stock item is not in stock'))
|
||||
elif self.require_in_stock == False:
|
||||
if stock_item.is_in_stock():
|
||||
raise ValidationError(_('Stock item is already in stock'))
|
||||
|
||||
return stock_item
|
||||
|
||||
@@ -1638,6 +1604,16 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Validate the quantity value."""
|
||||
if self.require_non_zero and quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be greater than zero'))
|
||||
|
||||
if quantity < 0:
|
||||
raise ValidationError(_('Quantity must not be negative'))
|
||||
|
||||
return quantity
|
||||
|
||||
batch = serializers.CharField(
|
||||
max_length=100,
|
||||
required=False,
|
||||
@@ -1775,8 +1751,10 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
|
||||
fields = ['items', 'notes', 'location']
|
||||
|
||||
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
@@ -1812,6 +1790,64 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
)
|
||||
|
||||
|
||||
class StockReturnSerializer(StockAdjustmentSerializer):
|
||||
"""Serializer class for returning stock item(s) into stock."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['items', 'notes', 'location']
|
||||
|
||||
items = StockAdjustmentItemSerializer(
|
||||
many=True, require_in_stock=False, require_non_zero=True
|
||||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination stock location'),
|
||||
)
|
||||
|
||||
merge = serializers.BooleanField(
|
||||
default=False,
|
||||
required=False,
|
||||
label=_('Merge into existing stock'),
|
||||
help_text=_('Merge returned items into existing stock items if possible'),
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Return the provided items into stock."""
|
||||
request = self.context['request']
|
||||
data = self.validated_data
|
||||
items = data['items']
|
||||
merge = data.get('merge', False)
|
||||
notes = data.get('notes', '')
|
||||
location = data['location']
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
stock_item = item['pk']
|
||||
quantity = item.get('quantity', None)
|
||||
|
||||
# Optional fields
|
||||
kwargs = {'notes': notes}
|
||||
|
||||
for field_name in StockItem.optional_transfer_fields():
|
||||
if field_value := item.get(field_name, None):
|
||||
kwargs[field_name] = field_value
|
||||
|
||||
stock_item.return_to_stock(
|
||||
location,
|
||||
quantity=quantity,
|
||||
merge=merge,
|
||||
user=request.user,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class StockItemSerialNumbersSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for extra serial number information about a stock item."""
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ class StockHistoryCode(StatusCode):
|
||||
STOCK_ADD = 11, _('Stock manually added')
|
||||
STOCK_REMOVE = 12, _('Stock manually removed')
|
||||
|
||||
RETURNED_TO_STOCK = 15, _('Returned to stock') # Stock item returned to stock
|
||||
|
||||
# Location operations
|
||||
STOCK_MOVE = 20, _('Location changed')
|
||||
STOCK_UPDATE = 25, _('Stock updated')
|
||||
|
||||
@@ -1586,16 +1586,33 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
n_entries = item.tracking_info_count
|
||||
|
||||
url = reverse('api-stock-item-return', kwargs={'pk': item.pk})
|
||||
url = reverse('api-stock-return')
|
||||
|
||||
# Empty POST will fail
|
||||
response = self.post(url, {}, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data['items']))
|
||||
self.assertIn('This field is required', str(response.data['location']))
|
||||
|
||||
# Test condition where provided quantity is zero
|
||||
response = self.post(
|
||||
url,
|
||||
{'items': [{'pk': item.pk, 'quantity': 0}], 'location': '1'},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Quantity must be greater than zero',
|
||||
str(response.data['items'][0]['quantity']),
|
||||
)
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{'location': '1', 'notes': 'Returned from this customer for testing'},
|
||||
{
|
||||
'items': [{'pk': item.pk, 'quantity': item.quantity}],
|
||||
'location': '1',
|
||||
'notes': 'Returned from this customer for testing',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
|
||||
@@ -457,7 +457,9 @@ class StockTest(StockTestBase):
|
||||
it.refresh_from_db()
|
||||
self.assertEqual(it.quantity, 10)
|
||||
|
||||
ait.return_from_customer(it.location, None, notes='Stock removed from customer')
|
||||
ait.return_from_customer(
|
||||
it.location, None, merge=True, notes='Stock returned from customer'
|
||||
)
|
||||
|
||||
# When returned stock is returned to its original (parent) location, check that the parent has correct quantity
|
||||
it.refresh_from_db()
|
||||
|
||||
Reference in New Issue
Block a user