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"
|
||||
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.
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 380
|
||||
INVENTREE_API_VERSION = 381
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v381 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10132
|
||||
- Refactor the "return stock item" API endpoint to align with other stock adjustment actions
|
||||
|
||||
v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10135
|
||||
- Fixes "issued_by" filter for the BuildOrder list API endpoint
|
||||
|
||||
v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10132
|
||||
- Refactor the "return stock item" API endpoint to align with other stock adjustment actions
|
||||
|
||||
v380 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10135
|
||||
- Fixes "issued_by" filter for the BuildOrder list API endpoint
|
||||
|
||||
|
||||
@@ -671,10 +671,14 @@ class Build(
|
||||
get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS')
|
||||
and self.has_open_child_builds
|
||||
):
|
||||
return
|
||||
raise ValidationError(
|
||||
_('Cannot complete build order with open child builds')
|
||||
)
|
||||
|
||||
if self.incomplete_count > 0:
|
||||
return
|
||||
raise ValidationError(
|
||||
_('Cannot complete build order with incomplete outputs')
|
||||
)
|
||||
|
||||
if trim_allocated_stock:
|
||||
self.trim_allocated_stock()
|
||||
|
||||
@@ -50,11 +50,11 @@ def complete_build_allocations(build_id: int, user_id: int):
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
user = None
|
||||
logger.warning(
|
||||
'Could not complete build allocations for BuildOrder <%s> - User does not exist',
|
||||
build_id,
|
||||
)
|
||||
return
|
||||
else:
|
||||
user = None
|
||||
|
||||
|
||||
@@ -498,9 +498,44 @@ class BuildTest(BuildTestBase):
|
||||
self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
|
||||
|
||||
# Check that the "consumed_by" item count has increased
|
||||
self.assertEqual(
|
||||
StockItem.objects.filter(consumed_by=self.build).count(), n + 8
|
||||
)
|
||||
consumed_items = StockItem.objects.filter(consumed_by=self.build)
|
||||
self.assertEqual(consumed_items.count(), n + 8)
|
||||
|
||||
# Finally, return the items into stock
|
||||
location = StockLocation.objects.filter(structural=False).first()
|
||||
|
||||
for item in consumed_items:
|
||||
item.return_to_stock(location)
|
||||
|
||||
# No consumed items should remain
|
||||
self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), 0)
|
||||
|
||||
def test_return_consumed(self):
|
||||
"""Test returning consumed stock items to stock."""
|
||||
self.build.auto_allocate_stock(interchangeable=True)
|
||||
|
||||
self.build.incomplete_outputs.delete()
|
||||
|
||||
self.assertGreater(self.build.allocated_stock.count(), 0)
|
||||
|
||||
self.build.complete_build(self.user)
|
||||
consumed_items = StockItem.objects.filter(consumed_by=self.build)
|
||||
self.assertGreater(consumed_items.count(), 0)
|
||||
|
||||
location = StockLocation.objects.filter(structural=False).last()
|
||||
|
||||
# Return a partial quantity of each item to stock
|
||||
for item in consumed_items:
|
||||
self.assertEqual(item.get_descendant_count(), 0)
|
||||
q = item.quantity
|
||||
self.assertGreater(item.quantity, 1)
|
||||
item.return_to_stock(location, merge=False, quantity=1)
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.quantity, q - 1)
|
||||
self.assertEqual(item.get_descendant_count(), 1)
|
||||
self.assertFalse(item.is_in_stock())
|
||||
child = item.get_descendants().first()
|
||||
self.assertTrue(child.is_in_stock())
|
||||
|
||||
def test_change_part(self):
|
||||
"""Try to change target part after creating a build."""
|
||||
|
||||
@@ -161,12 +161,6 @@ class StockItemConvert(StockItemContextMixin, CreateAPI):
|
||||
serializer_class = StockSerializers.ConvertStockItemSerializer
|
||||
|
||||
|
||||
class StockItemReturn(StockItemContextMixin, CreateAPI):
|
||||
"""API endpoint for returning a stock item from a customer."""
|
||||
|
||||
serializer_class = StockSerializers.ReturnStockItemSerializer
|
||||
|
||||
|
||||
class StockAdjustView(CreateAPI):
|
||||
"""A generic class for handling stocktake actions.
|
||||
|
||||
@@ -218,6 +212,17 @@ class StockTransfer(StockAdjustView):
|
||||
serializer_class = StockSerializers.StockTransferSerializer
|
||||
|
||||
|
||||
class StockReturn(StockAdjustView):
|
||||
"""API endpoint for returning items into stock.
|
||||
|
||||
This API endpoint is for items that are initially considered "not in stock",
|
||||
and the user wants to return them to stock, marking them as
|
||||
"available" for further consumption or sale.
|
||||
"""
|
||||
|
||||
serializer_class = StockSerializers.StockReturnSerializer
|
||||
|
||||
|
||||
class StockAssign(CreateAPI):
|
||||
"""API endpoint for assigning stock to a particular customer."""
|
||||
|
||||
@@ -1600,6 +1605,7 @@ stock_api_urls = [
|
||||
path('add/', StockAdd.as_view(), name='api-stock-add'),
|
||||
path('remove/', StockRemove.as_view(), name='api-stock-remove'),
|
||||
path('transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
|
||||
path('return/', StockReturn.as_view(), name='api-stock-return'),
|
||||
path('assign/', StockAssign.as_view(), name='api-stock-assign'),
|
||||
path('merge/', StockMerge.as_view(), name='api-stock-merge'),
|
||||
path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'),
|
||||
@@ -1657,7 +1663,6 @@ stock_api_urls = [
|
||||
MetadataView.as_view(model=StockItem),
|
||||
name='api-stock-item-metadata',
|
||||
),
|
||||
path('return/', StockItemReturn.as_view(), name='api-stock-item-return'),
|
||||
path(
|
||||
'serialize/',
|
||||
StockItemSerialize.as_view(),
|
||||
|
||||
@@ -8,6 +8,7 @@ class StockEvents(BaseEventEnum):
|
||||
|
||||
# StockItem events
|
||||
ITEM_ASSIGNED_TO_CUSTOMER = 'stockitem.assignedtocustomer'
|
||||
ITEM_RETURNED_TO_STOCK = 'stockitem.returnedtostock'
|
||||
ITEM_RETURNED_FROM_CUSTOMER = 'stockitem.returnedfromcustomer'
|
||||
ITEM_SPLIT = 'stockitem.split'
|
||||
ITEM_MOVED = 'stockitem.moved'
|
||||
|
||||
@@ -1387,47 +1387,101 @@ class StockItem(
|
||||
|
||||
If the selected location is the same as the parent, merge stock back into the parent.
|
||||
Otherwise create the stock in the new location.
|
||||
|
||||
Note that this function is provided for legacy compatibility,
|
||||
and the 'return_to_stock' function should be used instead.
|
||||
"""
|
||||
self.return_to_stock(
|
||||
location,
|
||||
user,
|
||||
tracking_code=StockHistoryCode.RETURNED_FROM_CUSTOMER,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def return_to_stock(
|
||||
self, location, user=None, quantity=None, merge: bool = True, **kwargs
|
||||
):
|
||||
"""Return stock item into stock, removing any consumption status.
|
||||
|
||||
Arguments:
|
||||
location: The location to return the stock item to
|
||||
user: The user performing the action
|
||||
quantity: If specified, the quantity to return to stock (default is the full quantity)
|
||||
merge: If True, attempt to merge this stock item back into the parent stock item
|
||||
"""
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
tracking_info = {'location': location.pk}
|
||||
tracking_code = kwargs.get('tracking_code', StockHistoryCode.RETURNED_TO_STOCK)
|
||||
|
||||
if self.customer:
|
||||
tracking_info['customer'] = self.customer.id
|
||||
tracking_info['customer_name'] = self.customer.name
|
||||
item = self
|
||||
|
||||
if quantity is not None and not self.serialized:
|
||||
# If quantity is specified, we are splitting the stock item
|
||||
if quantity <= 0:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be greater than zero')
|
||||
})
|
||||
|
||||
if quantity > self.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity exceeds available stock')
|
||||
})
|
||||
|
||||
if quantity < self.quantity:
|
||||
# Split the stock item
|
||||
item = self.splitStock(quantity, None, user)
|
||||
|
||||
tracking_info = {}
|
||||
|
||||
if location:
|
||||
tracking_info['location'] = location.pk
|
||||
|
||||
if item.customer:
|
||||
tracking_info['customer'] = item.customer.id
|
||||
tracking_info['customer_name'] = item.customer.name
|
||||
|
||||
if item.consumed_by:
|
||||
tracking_info['build_order'] = item.consumed_by.id
|
||||
|
||||
# Clear out allocation information for the stock item
|
||||
self.customer = None
|
||||
self.belongs_to = None
|
||||
self.sales_order = None
|
||||
self.location = location
|
||||
item.consumed_by = None
|
||||
item.customer = None
|
||||
item.belongs_to = None
|
||||
item.sales_order = None
|
||||
item.location = location
|
||||
|
||||
if status := kwargs.pop('status', None):
|
||||
if not self.compare_status(status):
|
||||
self.set_status(status)
|
||||
if not item.compare_status(status):
|
||||
item.set_status(status)
|
||||
tracking_info['status'] = status
|
||||
|
||||
self.save()
|
||||
item.save()
|
||||
|
||||
self.clearAllocations()
|
||||
item.clearAllocations()
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.RETURNED_FROM_CUSTOMER,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas=tracking_info,
|
||||
location=location,
|
||||
item.add_tracking_entry(
|
||||
tracking_code, user, notes=notes, deltas=tracking_info, location=location
|
||||
)
|
||||
|
||||
trigger_event(StockEvents.ITEM_RETURNED_FROM_CUSTOMER, id=self.id)
|
||||
trigger_event(StockEvents.ITEM_RETURNED_TO_STOCK, id=item.id)
|
||||
|
||||
"""If new location is the same as the parent location, merge this stock back in the parent"""
|
||||
if self.parent and self.location == self.parent.location:
|
||||
# Attempt to merge returned item into parent item:
|
||||
# - 'merge' parameter is True
|
||||
# - The parent location is the same as the current location
|
||||
# - The item does not have a serial number
|
||||
|
||||
if (
|
||||
merge
|
||||
and not item.serialized
|
||||
and self.parent
|
||||
and item.location == self.parent.location
|
||||
):
|
||||
self.parent.merge_stock_items(
|
||||
{self}, user=user, location=location, notes=notes
|
||||
{item}, user=user, location=location, notes=notes
|
||||
)
|
||||
else:
|
||||
self.save(add_note=False)
|
||||
item.save(add_note=False)
|
||||
|
||||
def is_allocated(self):
|
||||
"""Return True if this StockItem is allocated to a SalesOrder or a Build."""
|
||||
|
||||
@@ -1020,53 +1020,6 @@ class StockStatusCustomSerializer(serializers.ChoiceField):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class ReturnStockItemSerializer(serializers.Serializer):
|
||||
"""DRF serializer for returning a stock item from a customer."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['location', 'status', 'notes']
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination location for returned item'),
|
||||
)
|
||||
|
||||
status = StockStatusCustomSerializer(default=None, required=False, allow_blank=True)
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
help_text=_('Add transaction note (optional)'),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to return the item into stock."""
|
||||
item = self.context.get('item')
|
||||
|
||||
if not item:
|
||||
raise ValidationError(_('No stock item provided'))
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
location = data['location']
|
||||
|
||||
item.return_from_customer(
|
||||
location,
|
||||
user=request.user,
|
||||
notes=data.get('notes', ''),
|
||||
status=data.get('status', None),
|
||||
)
|
||||
|
||||
|
||||
class StockChangeStatusSerializer(serializers.Serializer):
|
||||
"""Serializer for changing status of multiple StockItem objects."""
|
||||
|
||||
@@ -1612,6 +1565,15 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
|
||||
fields = ['pk', 'quantity', 'batch', 'status', 'packaging']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the serializer."""
|
||||
# Store the 'require_in_stock' status
|
||||
# Either True / False / None
|
||||
self.require_in_stock = kwargs.pop('require_in_stock', True)
|
||||
self.require_non_zero = kwargs.pop('require_non_zero', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
pk = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
many=False,
|
||||
@@ -1623,14 +1585,18 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
|
||||
def validate_pk(self, stock_item: StockItem) -> StockItem:
|
||||
"""Ensure the stock item is valid."""
|
||||
allow_out_of_stock_transfer = get_global_setting(
|
||||
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False
|
||||
)
|
||||
if self.require_in_stock == True:
|
||||
allow_out_of_stock_transfer = get_global_setting(
|
||||
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False
|
||||
)
|
||||
|
||||
if not allow_out_of_stock_transfer and not stock_item.is_in_stock(
|
||||
check_status=False, check_quantity=False
|
||||
):
|
||||
raise ValidationError(_('Stock item is not in stock'))
|
||||
if not allow_out_of_stock_transfer and not stock_item.is_in_stock(
|
||||
check_status=False, check_quantity=False
|
||||
):
|
||||
raise ValidationError(_('Stock item is not in stock'))
|
||||
elif self.require_in_stock == False:
|
||||
if stock_item.is_in_stock():
|
||||
raise ValidationError(_('Stock item is already in stock'))
|
||||
|
||||
return stock_item
|
||||
|
||||
@@ -1638,6 +1604,16 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Validate the quantity value."""
|
||||
if self.require_non_zero and quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be greater than zero'))
|
||||
|
||||
if quantity < 0:
|
||||
raise ValidationError(_('Quantity must not be negative'))
|
||||
|
||||
return quantity
|
||||
|
||||
batch = serializers.CharField(
|
||||
max_length=100,
|
||||
required=False,
|
||||
@@ -1775,8 +1751,10 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
|
||||
fields = ['items', 'notes', 'location']
|
||||
|
||||
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
@@ -1812,6 +1790,64 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
)
|
||||
|
||||
|
||||
class StockReturnSerializer(StockAdjustmentSerializer):
|
||||
"""Serializer class for returning stock item(s) into stock."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['items', 'notes', 'location']
|
||||
|
||||
items = StockAdjustmentItemSerializer(
|
||||
many=True, require_in_stock=False, require_non_zero=True
|
||||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination stock location'),
|
||||
)
|
||||
|
||||
merge = serializers.BooleanField(
|
||||
default=False,
|
||||
required=False,
|
||||
label=_('Merge into existing stock'),
|
||||
help_text=_('Merge returned items into existing stock items if possible'),
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Return the provided items into stock."""
|
||||
request = self.context['request']
|
||||
data = self.validated_data
|
||||
items = data['items']
|
||||
merge = data.get('merge', False)
|
||||
notes = data.get('notes', '')
|
||||
location = data['location']
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
stock_item = item['pk']
|
||||
quantity = item.get('quantity', None)
|
||||
|
||||
# Optional fields
|
||||
kwargs = {'notes': notes}
|
||||
|
||||
for field_name in StockItem.optional_transfer_fields():
|
||||
if field_value := item.get(field_name, None):
|
||||
kwargs[field_name] = field_value
|
||||
|
||||
stock_item.return_to_stock(
|
||||
location,
|
||||
quantity=quantity,
|
||||
merge=merge,
|
||||
user=request.user,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class StockItemSerialNumbersSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for extra serial number information about a stock item."""
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ class StockHistoryCode(StatusCode):
|
||||
STOCK_ADD = 11, _('Stock manually added')
|
||||
STOCK_REMOVE = 12, _('Stock manually removed')
|
||||
|
||||
RETURNED_TO_STOCK = 15, _('Returned to stock') # Stock item returned to stock
|
||||
|
||||
# Location operations
|
||||
STOCK_MOVE = 20, _('Location changed')
|
||||
STOCK_UPDATE = 25, _('Stock updated')
|
||||
|
||||
@@ -1586,16 +1586,33 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
n_entries = item.tracking_info_count
|
||||
|
||||
url = reverse('api-stock-item-return', kwargs={'pk': item.pk})
|
||||
url = reverse('api-stock-return')
|
||||
|
||||
# Empty POST will fail
|
||||
response = self.post(url, {}, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data['items']))
|
||||
self.assertIn('This field is required', str(response.data['location']))
|
||||
|
||||
# Test condition where provided quantity is zero
|
||||
response = self.post(
|
||||
url,
|
||||
{'items': [{'pk': item.pk, 'quantity': 0}], 'location': '1'},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Quantity must be greater than zero',
|
||||
str(response.data['items'][0]['quantity']),
|
||||
)
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{'location': '1', 'notes': 'Returned from this customer for testing'},
|
||||
{
|
||||
'items': [{'pk': item.pk, 'quantity': item.quantity}],
|
||||
'location': '1',
|
||||
'notes': 'Returned from this customer for testing',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
|
||||
@@ -457,7 +457,9 @@ class StockTest(StockTestBase):
|
||||
it.refresh_from_db()
|
||||
self.assertEqual(it.quantity, 10)
|
||||
|
||||
ait.return_from_customer(it.location, None, notes='Stock removed from customer')
|
||||
ait.return_from_customer(
|
||||
it.location, None, merge=True, notes='Stock returned from customer'
|
||||
)
|
||||
|
||||
# When returned stock is returned to its original (parent) location, check that the parent has correct quantity
|
||||
it.refresh_from_db()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<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 {
|
||||
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: (
|
||||
<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,
|
||||
endpoint: ApiEndpoints.stock_remove,
|
||||
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,
|
||||
endpoint: ApiEndpoints.stock_transfer,
|
||||
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,
|
||||
endpoint: ApiEndpoints.stock_count,
|
||||
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,
|
||||
endpoint: ApiEndpoints.stock_change_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,
|
||||
modalFunc: useDeleteApiFormModal,
|
||||
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 {
|
||||
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,
|
||||
|
||||
@@ -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: <InvenTreeIcon icon='return' iconProps={{ color: 'blue' }} />,
|
||||
tooltip: t`Return selected items into stock`,
|
||||
onClick: () => {
|
||||
returnStock.open();
|
||||
}
|
||||
});
|
||||
|
||||
props.delete != false &&
|
||||
menuActions.push({
|
||||
name: t`Delete Stock`,
|
||||
|
||||
@@ -443,6 +443,7 @@ export default function BuildDetail() {
|
||||
allowAdd={false}
|
||||
tableName='build-consumed'
|
||||
showLocation={false}
|
||||
allowReturn
|
||||
params={{
|
||||
consumed_by: id
|
||||
}}
|
||||
|
||||
@@ -248,6 +248,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
allowAdd={false}
|
||||
tableName='assigned-stock'
|
||||
showLocation={false}
|
||||
allowReturn
|
||||
params={{ customer: company.pk }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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: (
|
||||
<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({
|
||||
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: (
|
||||
<InvenTreeIcon
|
||||
icon='return_orders'
|
||||
iconProps={{ color: 'blue' }}
|
||||
/>
|
||||
),
|
||||
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}
|
||||
</>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user