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:
BIN
docs/docs/assets/images/build/build_return_stock.png
Normal file
BIN
docs/docs/assets/images/build/build_return_stock.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/docs/assets/images/build/build_return_stock_dialog.png
Normal file
BIN
docs/docs/assets/images/build/build_return_stock_dialog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user