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' });