2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-29 12:27:41 +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:
Oliver
2025-08-07 10:47:26 +10:00
committed by GitHub
parent c4011d0be3
commit f1b5f2c379
23 changed files with 427 additions and 143 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -146,6 +146,16 @@ The *Consumed Stock* tab displays all stock items which have been *consumed* by
!!! info "No BOM" !!! info "No BOM"
If the part being built does not have a BOM, then the *Consumed Stock* tab will not be displayed. 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 ### Incomplete Outputs
The *Incomplete Outputs* panel shows the list of in-progress [build outputs](./output.md) (created stock items) associated with this build. The *Incomplete Outputs* panel shows the list of in-progress [build outputs](./output.md) (created stock items) associated with this build.

View File

@@ -1,12 +1,21 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10135
- Fixes "issued_by" filter for the BuildOrder list API endpoint - Fixes "issued_by" filter for the BuildOrder list API endpoint

View File

@@ -671,10 +671,14 @@ class Build(
get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS')
and self.has_open_child_builds and self.has_open_child_builds
): ):
return raise ValidationError(
_('Cannot complete build order with open child builds')
)
if self.incomplete_count > 0: if self.incomplete_count > 0:
return raise ValidationError(
_('Cannot complete build order with incomplete outputs')
)
if trim_allocated_stock: if trim_allocated_stock:
self.trim_allocated_stock() self.trim_allocated_stock()

View File

@@ -50,11 +50,11 @@ def complete_build_allocations(build_id: int, user_id: int):
try: try:
user = User.objects.get(pk=user_id) user = User.objects.get(pk=user_id)
except User.DoesNotExist: except User.DoesNotExist:
user = None
logger.warning( logger.warning(
'Could not complete build allocations for BuildOrder <%s> - User does not exist', 'Could not complete build allocations for BuildOrder <%s> - User does not exist',
build_id, build_id,
) )
return
else: else:
user = None user = None

View File

@@ -498,9 +498,44 @@ class BuildTest(BuildTestBase):
self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980) self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
# Check that the "consumed_by" item count has increased # Check that the "consumed_by" item count has increased
self.assertEqual( consumed_items = StockItem.objects.filter(consumed_by=self.build)
StockItem.objects.filter(consumed_by=self.build).count(), n + 8 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): def test_change_part(self):
"""Try to change target part after creating a build.""" """Try to change target part after creating a build."""

View File

@@ -161,12 +161,6 @@ class StockItemConvert(StockItemContextMixin, CreateAPI):
serializer_class = StockSerializers.ConvertStockItemSerializer 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): class StockAdjustView(CreateAPI):
"""A generic class for handling stocktake actions. """A generic class for handling stocktake actions.
@@ -218,6 +212,17 @@ class StockTransfer(StockAdjustView):
serializer_class = StockSerializers.StockTransferSerializer 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): class StockAssign(CreateAPI):
"""API endpoint for assigning stock to a particular customer.""" """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('add/', StockAdd.as_view(), name='api-stock-add'),
path('remove/', StockRemove.as_view(), name='api-stock-remove'), path('remove/', StockRemove.as_view(), name='api-stock-remove'),
path('transfer/', StockTransfer.as_view(), name='api-stock-transfer'), 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('assign/', StockAssign.as_view(), name='api-stock-assign'),
path('merge/', StockMerge.as_view(), name='api-stock-merge'), path('merge/', StockMerge.as_view(), name='api-stock-merge'),
path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'), path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'),
@@ -1657,7 +1663,6 @@ stock_api_urls = [
MetadataView.as_view(model=StockItem), MetadataView.as_view(model=StockItem),
name='api-stock-item-metadata', name='api-stock-item-metadata',
), ),
path('return/', StockItemReturn.as_view(), name='api-stock-item-return'),
path( path(
'serialize/', 'serialize/',
StockItemSerialize.as_view(), StockItemSerialize.as_view(),

View File

@@ -8,6 +8,7 @@ class StockEvents(BaseEventEnum):
# StockItem events # StockItem events
ITEM_ASSIGNED_TO_CUSTOMER = 'stockitem.assignedtocustomer' ITEM_ASSIGNED_TO_CUSTOMER = 'stockitem.assignedtocustomer'
ITEM_RETURNED_TO_STOCK = 'stockitem.returnedtostock'
ITEM_RETURNED_FROM_CUSTOMER = 'stockitem.returnedfromcustomer' ITEM_RETURNED_FROM_CUSTOMER = 'stockitem.returnedfromcustomer'
ITEM_SPLIT = 'stockitem.split' ITEM_SPLIT = 'stockitem.split'
ITEM_MOVED = 'stockitem.moved' ITEM_MOVED = 'stockitem.moved'

View File

@@ -1387,47 +1387,101 @@ class StockItem(
If the selected location is the same as the parent, merge stock back into the parent. If the selected location is the same as the parent, merge stock back into the parent.
Otherwise create the stock in the new location. 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', '') notes = kwargs.get('notes', '')
tracking_info = {'location': location.pk} tracking_code = kwargs.get('tracking_code', StockHistoryCode.RETURNED_TO_STOCK)
if self.customer: item = self
tracking_info['customer'] = self.customer.id
tracking_info['customer_name'] = self.customer.name 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 # Clear out allocation information for the stock item
self.customer = None item.consumed_by = None
self.belongs_to = None item.customer = None
self.sales_order = None item.belongs_to = None
self.location = location item.sales_order = None
item.location = location
if status := kwargs.pop('status', None): if status := kwargs.pop('status', None):
if not self.compare_status(status): if not item.compare_status(status):
self.set_status(status) item.set_status(status)
tracking_info['status'] = status tracking_info['status'] = status
self.save() item.save()
self.clearAllocations() item.clearAllocations()
self.add_tracking_entry( item.add_tracking_entry(
StockHistoryCode.RETURNED_FROM_CUSTOMER, tracking_code, user, notes=notes, deltas=tracking_info, location=location
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""" # Attempt to merge returned item into parent item:
if self.parent and self.location == self.parent.location: # - '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.parent.merge_stock_items(
{self}, user=user, location=location, notes=notes {item}, user=user, location=location, notes=notes
) )
else: else:
self.save(add_note=False) item.save(add_note=False)
def is_allocated(self): def is_allocated(self):
"""Return True if this StockItem is allocated to a SalesOrder or a Build.""" """Return True if this StockItem is allocated to a SalesOrder or a Build."""

View File

@@ -1020,53 +1020,6 @@ class StockStatusCustomSerializer(serializers.ChoiceField):
super().__init__(*args, **kwargs) 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): class StockChangeStatusSerializer(serializers.Serializer):
"""Serializer for changing status of multiple StockItem objects.""" """Serializer for changing status of multiple StockItem objects."""
@@ -1612,6 +1565,15 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
fields = ['pk', 'quantity', 'batch', 'status', 'packaging'] 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( pk = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(), queryset=StockItem.objects.all(),
many=False, many=False,
@@ -1623,14 +1585,18 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
def validate_pk(self, stock_item: StockItem) -> StockItem: def validate_pk(self, stock_item: StockItem) -> StockItem:
"""Ensure the stock item is valid.""" """Ensure the stock item is valid."""
allow_out_of_stock_transfer = get_global_setting( if self.require_in_stock == True:
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False 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( if not allow_out_of_stock_transfer and not stock_item.is_in_stock(
check_status=False, check_quantity=False check_status=False, check_quantity=False
): ):
raise ValidationError(_('Stock item is not in stock')) 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 return stock_item
@@ -1638,6 +1604,16 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True 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( batch = serializers.CharField(
max_length=100, max_length=100,
required=False, required=False,
@@ -1775,8 +1751,10 @@ class StockTransferSerializer(StockAdjustmentSerializer):
fields = ['items', 'notes', 'location'] fields = ['items', 'notes', 'location']
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
location = serializers.PrimaryKeyRelatedField( location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(), queryset=StockLocation.objects.filter(structural=False),
many=False, many=False,
required=True, required=True,
allow_null=False, 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): class StockItemSerialNumbersSerializer(InvenTreeModelSerializer):
"""Serializer for extra serial number information about a stock item.""" """Serializer for extra serial number information about a stock item."""

View File

@@ -54,6 +54,8 @@ class StockHistoryCode(StatusCode):
STOCK_ADD = 11, _('Stock manually added') STOCK_ADD = 11, _('Stock manually added')
STOCK_REMOVE = 12, _('Stock manually removed') STOCK_REMOVE = 12, _('Stock manually removed')
RETURNED_TO_STOCK = 15, _('Returned to stock') # Stock item returned to stock
# Location operations # Location operations
STOCK_MOVE = 20, _('Location changed') STOCK_MOVE = 20, _('Location changed')
STOCK_UPDATE = 25, _('Stock updated') STOCK_UPDATE = 25, _('Stock updated')

View File

@@ -1586,16 +1586,33 @@ class StockItemTest(StockAPITestCase):
n_entries = item.tracking_info_count 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 # Empty POST will fail
response = self.post(url, {}, expected_code=400) 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'])) 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( response = self.post(
url, 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, expected_code=201,
) )

View File

@@ -457,7 +457,9 @@ class StockTest(StockTestBase):
it.refresh_from_db() it.refresh_from_db()
self.assertEqual(it.quantity, 10) 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 # When returned stock is returned to its original (parent) location, check that the parent has correct quantity
it.refresh_from_db() it.refresh_from_db()

View File

@@ -144,6 +144,7 @@ export enum ApiEndpoints {
stock_test_result_list = 'stock/test/', stock_test_result_list = 'stock/test/',
stock_transfer = 'stock/transfer/', stock_transfer = 'stock/transfer/',
stock_remove = 'stock/remove/', stock_remove = 'stock/remove/',
stock_return = 'stock/return/',
stock_add = 'stock/add/', stock_add = 'stock/add/',
stock_count = 'stock/count/', stock_count = 'stock/count/',
stock_change_status = 'stock/change_status/', stock_change_status = 'stock/change_status/',
@@ -153,7 +154,6 @@ export enum ApiEndpoints {
stock_install = 'stock/:id/install/', stock_install = 'stock/:id/install/',
stock_uninstall = 'stock/:id/uninstall/', stock_uninstall = 'stock/:id/uninstall/',
stock_serialize = 'stock/:id/serialize/', stock_serialize = 'stock/:id/serialize/',
stock_return = 'stock/:id/return/',
stock_serial_info = 'stock/:id/serial-numbers/', stock_serial_info = 'stock/:id/serial-numbers/',
// Generator API endpoints // Generator API endpoints

View File

@@ -746,6 +746,54 @@ function stockTransferFields(items: any[]): ApiFormFieldSet {
return fields; 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 (
<StockOperationsRow
props={row}
key={record.pk}
record={record}
transfer
changeStatus
/>
);
},
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 { function stockRemoveFields(items: any[]): ApiFormFieldSet {
if (!items) { if (!items) {
return {}; return {};
@@ -1136,7 +1184,12 @@ export function useAddStockItem(props: StockOperationProps) {
fieldGenerator: stockAddFields, fieldGenerator: stockAddFields,
endpoint: ApiEndpoints.stock_add, endpoint: ApiEndpoints.stock_add,
title: t`Add Stock`, title: t`Add Stock`,
successMessage: t`Stock added` successMessage: t`Stock added`,
preFormContent: (
<Alert color='blue'>
{t`Increase the quantity of the selected stock items by a given amount.`}
</Alert>
)
}); });
} }
@@ -1146,7 +1199,12 @@ export function useRemoveStockItem(props: StockOperationProps) {
fieldGenerator: stockRemoveFields, fieldGenerator: stockRemoveFields,
endpoint: ApiEndpoints.stock_remove, endpoint: ApiEndpoints.stock_remove,
title: t`Remove Stock`, title: t`Remove Stock`,
successMessage: t`Stock removed` successMessage: t`Stock removed`,
preFormContent: (
<Alert color='blue'>
{t`Decrease the quantity of the selected stock items by a given amount.`}
</Alert>
)
}); });
} }
@@ -1156,7 +1214,27 @@ export function useTransferStockItem(props: StockOperationProps) {
fieldGenerator: stockTransferFields, fieldGenerator: stockTransferFields,
endpoint: ApiEndpoints.stock_transfer, endpoint: ApiEndpoints.stock_transfer,
title: t`Transfer Stock`, title: t`Transfer Stock`,
successMessage: t`Stock transferred` successMessage: t`Stock transferred`,
preFormContent: (
<Alert color='blue'>
{t`Transfer selected items to the specified location.`}
</Alert>
)
});
}
export function useReturnStockItem(props: StockOperationProps) {
return useStockOperationModal({
...props,
fieldGenerator: stockReturnFields,
endpoint: ApiEndpoints.stock_return,
title: t`Return Stock`,
successMessage: t`Stock returned`,
preFormContent: (
<Alert color='blue'>
{t`Return selected items into stock, to the specified location.`}
</Alert>
)
}); });
} }
@@ -1166,7 +1244,12 @@ export function useCountStockItem(props: StockOperationProps) {
fieldGenerator: stockCountFields, fieldGenerator: stockCountFields,
endpoint: ApiEndpoints.stock_count, endpoint: ApiEndpoints.stock_count,
title: t`Count Stock`, title: t`Count Stock`,
successMessage: t`Stock counted` successMessage: t`Stock counted`,
preFormContent: (
<Alert color='blue'>
{t`Count the selected stock items, and adjust the quantity accordingly.`}
</Alert>
)
}); });
} }
@@ -1176,7 +1259,12 @@ export function useChangeStockStatus(props: StockOperationProps) {
fieldGenerator: stockChangeStatusFields, fieldGenerator: stockChangeStatusFields,
endpoint: ApiEndpoints.stock_change_status, endpoint: ApiEndpoints.stock_change_status,
title: t`Change Stock Status`, title: t`Change Stock Status`,
successMessage: t`Stock status changed` successMessage: t`Stock status changed`,
preFormContent: (
<Alert color='blue'>
{t`Change the status of the selected stock items.`}
</Alert>
)
}); });
} }
@@ -1222,7 +1310,12 @@ export function useDeleteStockItem(props: StockOperationProps) {
endpoint: ApiEndpoints.stock_item_list, endpoint: ApiEndpoints.stock_item_list,
modalFunc: useDeleteApiFormModal, modalFunc: useDeleteApiFormModal,
title: t`Delete Stock Items`, title: t`Delete Stock Items`,
successMessage: t`Stock deleted` successMessage: t`Stock deleted`,
preFormContent: (
<Alert color='red'>
{t`This operation will permanently delete the selected stock items.`}
</Alert>
)
}); });
} }

View File

@@ -1,5 +1,6 @@
import type { InvenTreeIconType, TablerIconType } from '@lib/types/Icons'; import type { InvenTreeIconType, TablerIconType } from '@lib/types/Icons';
import { import {
IconArrowBack,
IconArrowBigDownLineFilled, IconArrowBigDownLineFilled,
IconArrowMerge, IconArrowMerge,
IconBell, IconBell,
@@ -165,6 +166,7 @@ const icons: InvenTreeIconType = {
refresh: IconRefresh, refresh: IconRefresh,
select_image: IconGridDots, select_image: IconGridDots,
delete: IconTrash, delete: IconTrash,
return: IconArrowBack,
packaging: IconPackage, packaging: IconPackage,
packages: IconPackages, packages: IconPackages,
install: IconTransitionRight, install: IconTransitionRight,

View File

@@ -13,6 +13,7 @@ import {
useDeleteStockItem, useDeleteStockItem,
useMergeStockItem, useMergeStockItem,
useRemoveStockItem, useRemoveStockItem,
useReturnStockItem,
useTransferStockItem useTransferStockItem
} from '../forms/StockForms'; } from '../forms/StockForms';
import { InvenTreeIcon } from '../functions/icons'; import { InvenTreeIcon } from '../functions/icons';
@@ -29,6 +30,7 @@ interface StockAdjustActionProps {
merge?: boolean; merge?: boolean;
remove?: boolean; remove?: boolean;
transfer?: boolean; transfer?: boolean;
return?: boolean;
} }
interface StockAdjustActionReturnProps { interface StockAdjustActionReturnProps {
@@ -58,6 +60,7 @@ export function useStockAdjustActions(
const mergeStock = useMergeStockItem(props.formProps); const mergeStock = useMergeStockItem(props.formProps);
const removeStock = useRemoveStockItem(props.formProps); const removeStock = useRemoveStockItem(props.formProps);
const transferStock = useTransferStockItem(props.formProps); const transferStock = useTransferStockItem(props.formProps);
const returnStock = useReturnStockItem(props.formProps);
// Construct a list of modals available for stock adjustment actions // Construct a list of modals available for stock adjustment actions
const modals: UseModalReturn[] = useMemo(() => { const modals: UseModalReturn[] = useMemo(() => {
@@ -74,6 +77,7 @@ export function useStockAdjustActions(
props.merge != false && modals.push(mergeStock); props.merge != false && modals.push(mergeStock);
props.remove != false && modals.push(removeStock); props.remove != false && modals.push(removeStock);
props.transfer != false && modals.push(transferStock); props.transfer != false && modals.push(transferStock);
props.return === true && modals.push(returnStock);
props.delete != false && props.delete != false &&
user.hasDeleteRole(UserRoles.stock) && user.hasDeleteRole(UserRoles.stock) &&
modals.push(deleteStock); modals.push(deleteStock);
@@ -159,6 +163,16 @@ export function useStockAdjustActions(
} }
}); });
props.return === true &&
menuActions.push({
name: t`Return Stock`,
icon: <InvenTreeIcon icon='return' iconProps={{ color: 'blue' }} />,
tooltip: t`Return selected items into stock`,
onClick: () => {
returnStock.open();
}
});
props.delete != false && props.delete != false &&
menuActions.push({ menuActions.push({
name: t`Delete Stock`, name: t`Delete Stock`,

View File

@@ -443,6 +443,7 @@ export default function BuildDetail() {
allowAdd={false} allowAdd={false}
tableName='build-consumed' tableName='build-consumed'
showLocation={false} showLocation={false}
allowReturn
params={{ params={{
consumed_by: id consumed_by: id
}} }}

View File

@@ -248,6 +248,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
allowAdd={false} allowAdd={false}
tableName='assigned-stock' tableName='assigned-stock'
showLocation={false} showLocation={false}
allowReturn
params={{ customer: company.pk }} params={{ customer: company.pk }}
/> />
) : ( ) : (

View File

@@ -1,7 +1,6 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { import {
Accordion, Accordion,
Alert,
Button, Button,
Grid, Grid,
Group, Group,
@@ -725,6 +724,8 @@ export default function StockDetail() {
const stockAdjustActions = useStockAdjustActions({ const stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps, formProps: stockOperationProps,
delete: false, delete: false,
assign: !!stockitem.in_stock,
return: !!stockitem.consumed_by || !!stockitem.customer,
merge: false merge: false
}); });
@@ -756,30 +757,6 @@ export default function StockDetail() {
successMessage: t`Stock item serialized` successMessage: t`Stock item serialized`
}); });
const returnStockItem = useCreateApiFormModal({
url: ApiEndpoints.stock_return,
pk: stockitem.pk,
title: t`Return Stock Item`,
preFormContent: (
<Alert color='blue'>
{t`Return this item into stock. This will remove the customer assignment.`}
</Alert>
),
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({ const orderPartsWizard = OrderPartsWizard({
parts: stockitem.part_detail ? [stockitem.part_detail] : [] parts: stockitem.part_detail ? [stockitem.part_detail] : []
}); });
@@ -884,20 +861,6 @@ export default function StockDetail() {
onClick: () => { onClick: () => {
orderPartsWizard.openWizard(); orderPartsWizard.openWizard();
} }
},
{
name: t`Return`,
tooltip: t`Return from customer`,
hidden: !stockitem.customer,
icon: (
<InvenTreeIcon
icon='return_orders'
iconProps={{ color: 'blue' }}
/>
),
onClick: () => {
stockitem.pk && returnStockItem.open();
}
} }
]} ]}
/>, />,
@@ -1045,7 +1008,6 @@ export default function StockDetail() {
{duplicateStockItem.modal} {duplicateStockItem.modal}
{deleteStockItem.modal} {deleteStockItem.modal}
{serializeStockItem.modal} {serializeStockItem.modal}
{returnStockItem.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)} {stockAdjustActions.modals.map((modal) => modal.modal)}
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
</> </>

View File

@@ -463,12 +463,14 @@ export function StockItemTable({
allowAdd = false, allowAdd = false,
showLocation = true, showLocation = true,
showPricing = true, showPricing = true,
allowReturn = false,
tableName = 'stockitems' tableName = 'stockitems'
}: Readonly<{ }: Readonly<{
params?: any; params?: any;
allowAdd?: boolean; allowAdd?: boolean;
showLocation?: boolean; showLocation?: boolean;
showPricing?: boolean; showPricing?: boolean;
allowReturn?: boolean;
tableName: string; tableName: string;
}>) { }>) {
const table = useTable(tableName); const table = useTable(tableName);
@@ -536,7 +538,8 @@ export function StockItemTable({
}); });
const stockAdjustActions = useStockAdjustActions({ const stockAdjustActions = useStockAdjustActions({
formProps: stockOperationProps formProps: stockOperationProps,
return: allowReturn
}); });
const tableActions = useMemo(() => { const tableActions = useMemo(() => {

View File

@@ -299,6 +299,39 @@ test('Stock - Stock Actions', async ({ browser }) => {
await page.getByLabel('action-menu-stock-operations-return').click(); 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 }) => { test('Stock - Tracking', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'stock/item/176/details' }); const page = await doCachedLogin(browser, { url: 'stock/item/176/details' });