diff --git a/docs/docs/assets/images/build/build_return_stock.png b/docs/docs/assets/images/build/build_return_stock.png new file mode 100644 index 0000000000..1302bb7c28 Binary files /dev/null and b/docs/docs/assets/images/build/build_return_stock.png differ diff --git a/docs/docs/assets/images/build/build_return_stock_dialog.png b/docs/docs/assets/images/build/build_return_stock_dialog.png new file mode 100644 index 0000000000..2fd555a3e6 Binary files /dev/null and b/docs/docs/assets/images/build/build_return_stock_dialog.png differ diff --git a/docs/docs/manufacturing/build.md b/docs/docs/manufacturing/build.md index ae4b9bf8e9..ae01395f69 100644 --- a/docs/docs/manufacturing/build.md +++ b/docs/docs/manufacturing/build.md @@ -146,6 +146,16 @@ The *Consumed Stock* tab displays all stock items which have been *consumed* by !!! info "No BOM" If the part being built does not have a BOM, then the *Consumed Stock* tab will not be displayed. +#### Return to Stock + +After stock items have been *consumed* by a build order, it may be required to recover some of that stock back into the inventory. This can be done by selecting the desired items, and pressing the *Return to Stock* button: + +{{ image("build/build_return_stock.png", title="Return Stock") }} + +This will open the following dialog, which allows the user to specify the quantity of stock to return, and the location where the stock should be returned: + +{{ image("build/build_return_stock_dialog.png", title="Return Stock Dialog") }} + ### Incomplete Outputs The *Incomplete Outputs* panel shows the list of in-progress [build outputs](./output.md) (created stock items) associated with this build. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index b837ab7622..6c3e7f0fae 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index e06ab5f890..fbf4d96e65 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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() diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 29fded5bc9..5ce4798a8d 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -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 diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index 0367f77e7f..d73989965b 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -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.""" diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 5407e76408..37faee8bff 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -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(), diff --git a/src/backend/InvenTree/stock/events.py b/src/backend/InvenTree/stock/events.py index 1dd2364d7b..6f6efb2f62 100644 --- a/src/backend/InvenTree/stock/events.py +++ b/src/backend/InvenTree/stock/events.py @@ -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' diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 8aed9e39d0..8857aa571d 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -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.""" diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 4fea859db9..a080b62780 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -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.""" diff --git a/src/backend/InvenTree/stock/status_codes.py b/src/backend/InvenTree/stock/status_codes.py index d59d622569..af99974d7e 100644 --- a/src/backend/InvenTree/stock/status_codes.py +++ b/src/backend/InvenTree/stock/status_codes.py @@ -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') diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index a134f5346c..a12f01b1d8 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -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, ) diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index f71270d821..6df3dddb1b 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -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() diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index b99e428e43..628b858c34 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -144,6 +144,7 @@ export enum ApiEndpoints { stock_test_result_list = 'stock/test/', stock_transfer = 'stock/transfer/', stock_remove = 'stock/remove/', + stock_return = 'stock/return/', stock_add = 'stock/add/', stock_count = 'stock/count/', stock_change_status = 'stock/change_status/', @@ -153,7 +154,6 @@ export enum ApiEndpoints { stock_install = 'stock/:id/install/', stock_uninstall = 'stock/:id/uninstall/', stock_serialize = 'stock/:id/serialize/', - stock_return = 'stock/:id/return/', stock_serial_info = 'stock/:id/serial-numbers/', // Generator API endpoints diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index f94ecc6184..b0ab6c25bc 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -746,6 +746,54 @@ function stockTransferFields(items: any[]): ApiFormFieldSet { return fields; } +function stockReturnFields(items: any[]): ApiFormFieldSet { + if (!items) { + return {}; + } + + // Only include items that are currently *not* in stock + const records = Object.fromEntries( + items.filter((item) => !item.in_stock).map((item) => [item.pk, item]) + ); + + const fields: ApiFormFieldSet = { + items: { + field_type: 'table', + value: mapAdjustmentItems(items), + modelRenderer: (row: TableFieldRowProps) => { + const record = records[row.item.pk]; + + return ( + + ); + }, + headers: [ + { title: t`Part` }, + { title: t`Location` }, + { title: t`Batch` }, + { title: t`Quantity` }, + { title: t`Return`, style: { width: '200px' } }, + { title: t`Actions` } + ] + }, + location: { + filters: { + structural: false + } + }, + merge: {}, + notes: {} + }; + + return fields; +} + function stockRemoveFields(items: any[]): ApiFormFieldSet { if (!items) { return {}; @@ -1136,7 +1184,12 @@ export function useAddStockItem(props: StockOperationProps) { fieldGenerator: stockAddFields, endpoint: ApiEndpoints.stock_add, title: t`Add Stock`, - successMessage: t`Stock added` + successMessage: t`Stock added`, + preFormContent: ( + + {t`Increase the quantity of the selected stock items by a given amount.`} + + ) }); } @@ -1146,7 +1199,12 @@ export function useRemoveStockItem(props: StockOperationProps) { fieldGenerator: stockRemoveFields, endpoint: ApiEndpoints.stock_remove, title: t`Remove Stock`, - successMessage: t`Stock removed` + successMessage: t`Stock removed`, + preFormContent: ( + + {t`Decrease the quantity of the selected stock items by a given amount.`} + + ) }); } @@ -1156,7 +1214,27 @@ export function useTransferStockItem(props: StockOperationProps) { fieldGenerator: stockTransferFields, endpoint: ApiEndpoints.stock_transfer, title: t`Transfer Stock`, - successMessage: t`Stock transferred` + successMessage: t`Stock transferred`, + preFormContent: ( + + {t`Transfer selected items to the specified location.`} + + ) + }); +} + +export function useReturnStockItem(props: StockOperationProps) { + return useStockOperationModal({ + ...props, + fieldGenerator: stockReturnFields, + endpoint: ApiEndpoints.stock_return, + title: t`Return Stock`, + successMessage: t`Stock returned`, + preFormContent: ( + + {t`Return selected items into stock, to the specified location.`} + + ) }); } @@ -1166,7 +1244,12 @@ export function useCountStockItem(props: StockOperationProps) { fieldGenerator: stockCountFields, endpoint: ApiEndpoints.stock_count, title: t`Count Stock`, - successMessage: t`Stock counted` + successMessage: t`Stock counted`, + preFormContent: ( + + {t`Count the selected stock items, and adjust the quantity accordingly.`} + + ) }); } @@ -1176,7 +1259,12 @@ export function useChangeStockStatus(props: StockOperationProps) { fieldGenerator: stockChangeStatusFields, endpoint: ApiEndpoints.stock_change_status, title: t`Change Stock Status`, - successMessage: t`Stock status changed` + successMessage: t`Stock status changed`, + preFormContent: ( + + {t`Change the status of the selected stock items.`} + + ) }); } @@ -1222,7 +1310,12 @@ export function useDeleteStockItem(props: StockOperationProps) { endpoint: ApiEndpoints.stock_item_list, modalFunc: useDeleteApiFormModal, title: t`Delete Stock Items`, - successMessage: t`Stock deleted` + successMessage: t`Stock deleted`, + preFormContent: ( + + {t`This operation will permanently delete the selected stock items.`} + + ) }); } diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index 1cf0a2d2f3..f411c20f95 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -1,5 +1,6 @@ import type { InvenTreeIconType, TablerIconType } from '@lib/types/Icons'; import { + IconArrowBack, IconArrowBigDownLineFilled, IconArrowMerge, IconBell, @@ -165,6 +166,7 @@ const icons: InvenTreeIconType = { refresh: IconRefresh, select_image: IconGridDots, delete: IconTrash, + return: IconArrowBack, packaging: IconPackage, packages: IconPackages, install: IconTransitionRight, diff --git a/src/frontend/src/hooks/UseStockAdjustActions.tsx b/src/frontend/src/hooks/UseStockAdjustActions.tsx index d6d994adbb..d9e9ba927e 100644 --- a/src/frontend/src/hooks/UseStockAdjustActions.tsx +++ b/src/frontend/src/hooks/UseStockAdjustActions.tsx @@ -13,6 +13,7 @@ import { useDeleteStockItem, useMergeStockItem, useRemoveStockItem, + useReturnStockItem, useTransferStockItem } from '../forms/StockForms'; import { InvenTreeIcon } from '../functions/icons'; @@ -29,6 +30,7 @@ interface StockAdjustActionProps { merge?: boolean; remove?: boolean; transfer?: boolean; + return?: boolean; } interface StockAdjustActionReturnProps { @@ -58,6 +60,7 @@ export function useStockAdjustActions( const mergeStock = useMergeStockItem(props.formProps); const removeStock = useRemoveStockItem(props.formProps); const transferStock = useTransferStockItem(props.formProps); + const returnStock = useReturnStockItem(props.formProps); // Construct a list of modals available for stock adjustment actions const modals: UseModalReturn[] = useMemo(() => { @@ -74,6 +77,7 @@ export function useStockAdjustActions( props.merge != false && modals.push(mergeStock); props.remove != false && modals.push(removeStock); props.transfer != false && modals.push(transferStock); + props.return === true && modals.push(returnStock); props.delete != false && user.hasDeleteRole(UserRoles.stock) && modals.push(deleteStock); @@ -159,6 +163,16 @@ export function useStockAdjustActions( } }); + props.return === true && + menuActions.push({ + name: t`Return Stock`, + icon: , + tooltip: t`Return selected items into stock`, + onClick: () => { + returnStock.open(); + } + }); + props.delete != false && menuActions.push({ name: t`Delete Stock`, diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 646de48483..810cc292e6 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -443,6 +443,7 @@ export default function BuildDetail() { allowAdd={false} tableName='build-consumed' showLocation={false} + allowReturn params={{ consumed_by: id }} diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index a108495509..ad52203c59 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -248,6 +248,7 @@ export default function CompanyDetail(props: Readonly) { allowAdd={false} tableName='assigned-stock' showLocation={false} + allowReturn params={{ customer: company.pk }} /> ) : ( diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 153024f14f..5b2c661787 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -1,7 +1,6 @@ import { t } from '@lingui/core/macro'; import { Accordion, - Alert, Button, Grid, Group, @@ -725,6 +724,8 @@ export default function StockDetail() { const stockAdjustActions = useStockAdjustActions({ formProps: stockOperationProps, delete: false, + assign: !!stockitem.in_stock, + return: !!stockitem.consumed_by || !!stockitem.customer, merge: false }); @@ -756,30 +757,6 @@ export default function StockDetail() { successMessage: t`Stock item serialized` }); - const returnStockItem = useCreateApiFormModal({ - url: ApiEndpoints.stock_return, - pk: stockitem.pk, - title: t`Return Stock Item`, - preFormContent: ( - - {t`Return this item into stock. This will remove the customer assignment.`} - - ), - fields: { - location: {}, - status: {}, - notes: {} - }, - initialData: { - location: stockitem.location ?? stockitem.part_detail?.default_location, - status: stockitem.status_custom_key ?? stockitem.status - }, - successMessage: t`Item returned to stock`, - onFormSuccess: () => { - refreshInstance(); - } - }); - const orderPartsWizard = OrderPartsWizard({ parts: stockitem.part_detail ? [stockitem.part_detail] : [] }); @@ -884,20 +861,6 @@ export default function StockDetail() { onClick: () => { orderPartsWizard.openWizard(); } - }, - { - name: t`Return`, - tooltip: t`Return from customer`, - hidden: !stockitem.customer, - icon: ( - - ), - onClick: () => { - stockitem.pk && returnStockItem.open(); - } } ]} />, @@ -1045,7 +1008,6 @@ export default function StockDetail() { {duplicateStockItem.modal} {deleteStockItem.modal} {serializeStockItem.modal} - {returnStockItem.modal} {stockAdjustActions.modals.map((modal) => modal.modal)} {orderPartsWizard.wizard} diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 7664386a4a..0e7f8c4523 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -463,12 +463,14 @@ export function StockItemTable({ allowAdd = false, showLocation = true, showPricing = true, + allowReturn = false, tableName = 'stockitems' }: Readonly<{ params?: any; allowAdd?: boolean; showLocation?: boolean; showPricing?: boolean; + allowReturn?: boolean; tableName: string; }>) { const table = useTable(tableName); @@ -536,7 +538,8 @@ export function StockItemTable({ }); const stockAdjustActions = useStockAdjustActions({ - formProps: stockOperationProps + formProps: stockOperationProps, + return: allowReturn }); const tableActions = useMemo(() => { diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index fd95e29709..2385b6058a 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -299,6 +299,39 @@ test('Stock - Stock Actions', async ({ browser }) => { await page.getByLabel('action-menu-stock-operations-return').click(); }); +test('Stock - Return Items', async ({ browser }) => { + const page = await doCachedLogin(browser, { + url: 'sales/customer/32/assigned-stock' + }); + + // Return stock items assigned to customer + await page.getByRole('cell', { name: 'Select all records' }).click(); + await page.getByRole('button', { name: 'action-menu-stock-actions' }).click(); + await page + .getByRole('menuitem', { name: 'action-menu-stock-actions-return-stock' }) + .click(); + await page.getByText('Return selected items into stock').first().waitFor(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + // Location detail + await navigate(page, 'stock/item/1253'); + await page + .getByRole('button', { name: 'action-menu-stock-operations' }) + .click(); + await page + .getByRole('menuitem', { + name: 'action-menu-stock-operations-return-stock' + }) + .click(); + await page.getByText('#128').waitFor(); + await page.getByText('Merge into existing stock').waitFor(); + await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('0'); + await page.getByRole('button', { name: 'Submit' }).click(); + + await page.getByText('Quantity must be greater than zero').waitFor(); + await page.getByText('This field is required.').waitFor(); +}); + test('Stock - Tracking', async ({ browser }) => { const page = await doCachedLogin(browser, { url: 'stock/item/176/details' });