mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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