diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 2d1b4e364b2..c90fe14e590 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 286 +INVENTREE_API_VERSION = 287 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v287 - 2024-11-27 : https://github.com/inventree/InvenTree/pull/8571 + - Adds ability to set stock status when returning items from a customer + v286 - 2024-11-26 : https://github.com/inventree/InvenTree/pull/8054 - Adds "SelectionList" and "SelectionListEntry" API endpoints diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index e07afacb933..2b1a0926339 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1254,7 +1254,9 @@ def test_refresh_endpoint(self): # Updating via the external exchange may not work every time for _idx in range(5): - self.post(reverse('api-currency-refresh'), expected_code=200) + self.post( + reverse('api-currency-refresh'), expected_code=200, max_query_time=30 + ) # There should be some new exchange rate objects now if Rate.objects.all().exists(): diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 85789747111..9eeeec0aff1 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -2363,14 +2363,23 @@ def cancel_order(self): # endregion @transaction.atomic - def receive_line_item(self, line, location, user, note='', **kwargs): + def receive_line_item(self, line, location, user, **kwargs): """Receive a line item against this ReturnOrder. - Rules: - - Transfers the StockItem to the specified location - - Marks the StockItem as "quarantined" - - Adds a tracking entry to the StockItem - - Removes the 'customer' reference from the StockItem + Arguments: + line: ReturnOrderLineItem to receive + location: StockLocation to receive the item to + user: User performing the action + + Keyword Arguments: + note: Additional notes to add to the tracking entry + status: Status to set the StockItem to (default: StockStatus.QUARANTINED) + + Performs the following actions: + - Transfers the StockItem to the specified location + - Marks the StockItem as "quarantined" + - Adds a tracking entry to the StockItem + - Removes the 'customer' reference from the StockItem """ # Prevent an item from being "received" multiple times if line.received_date is not None: @@ -2379,17 +2388,18 @@ def receive_line_item(self, line, location, user, note='', **kwargs): stock_item = line.item - deltas = { - 'status': StockStatus.QUARANTINED.value, - 'returnorder': self.pk, - 'location': location.pk, - } + status = kwargs.get('status') + + if status is None: + status = StockStatus.QUARANTINED.value + + deltas = {'status': status, 'returnorder': self.pk, 'location': location.pk} if stock_item.customer: deltas['customer'] = stock_item.customer.pk # Update the StockItem - stock_item.status = kwargs.get('status', StockStatus.QUARANTINED.value) + stock_item.status = status stock_item.location = location stock_item.customer = None stock_item.sales_order = None @@ -2400,7 +2410,7 @@ def receive_line_item(self, line, location, user, note='', **kwargs): stock_item.add_tracking_entry( StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER, user, - notes=note, + notes=kwargs.get('note', ''), deltas=deltas, location=location, returnorder=self, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 936b8e0f3ef..02955e957be 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -26,6 +26,7 @@ import part.models as part_models import stock.models import stock.serializers +import stock.status_codes from common.serializers import ProjectCodeSerializer from company.serializers import ( AddressBriefSerializer, @@ -1923,7 +1924,7 @@ class ReturnOrderLineItemReceiveSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = ['item'] + fields = ['item', 'status'] item = serializers.PrimaryKeyRelatedField( queryset=order.models.ReturnOrderLineItem.objects.all(), @@ -1933,6 +1934,15 @@ class Meta: label=_('Return order line item'), ) + status = serializers.ChoiceField( + choices=stock.status_codes.StockStatus.items(), + default=None, + label=_('Status'), + help_text=_('Stock item status code'), + required=False, + allow_blank=True, + ) + def validate_line_item(self, item): """Validation for a single line item.""" if item.order != self.context['order']: @@ -1950,7 +1960,7 @@ class ReturnOrderReceiveSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = ['items', 'location'] + fields = ['items', 'location', 'note'] items = ReturnOrderLineItemReceiveSerializer(many=True) @@ -1963,6 +1973,14 @@ class Meta: help_text=_('Select destination location for received items'), ) + note = serializers.CharField( + label=_('Note'), + help_text=_('Additional note for incoming stock items'), + required=False, + default='', + allow_blank=True, + ) + def validate(self, data): """Perform data validation for this serializer.""" order = self.context['order'] @@ -1993,7 +2011,14 @@ def save(self): with transaction.atomic(): for item in items: line_item = item['item'] - order.receive_line_item(line_item, location, request.user) + + order.receive_line_item( + line_item, + location, + request.user, + note=data.get('note', ''), + status=item.get('status', None), + ) @register_importer() diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index b414b8592ef..29aa84cc503 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -1217,8 +1217,16 @@ def allocateToCustomer( def return_from_customer(self, location, user=None, **kwargs): """Return stock item from customer, back into the specified location. + Arguments: + location: The location to return the stock item to + user: The user performing the action + + Keyword Arguments: + notes: Additional notes to add to the tracking entry + status: Optionally set the status of the stock item + 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. """ notes = kwargs.get('notes', '') @@ -1228,6 +1236,17 @@ def return_from_customer(self, location, user=None, **kwargs): tracking_info['customer'] = self.customer.id tracking_info['customer_name'] = self.customer.name + # Clear out allocation information for the stock item + self.customer = None + self.belongs_to = None + self.sales_order = None + self.location = location + self.clearAllocations() + + if status := kwargs.get('status'): + self.status = status + tracking_info['status'] = status + self.add_tracking_entry( StockHistoryCode.RETURNED_FROM_CUSTOMER, user, @@ -1236,13 +1255,6 @@ def return_from_customer(self, location, user=None, **kwargs): location=location, ) - # Clear out allocation information for the stock item - self.customer = None - self.belongs_to = None - self.sales_order = None - self.location = location - self.clearAllocations() - trigger_event('stockitem.returnedfromcustomer', id=self.id) """If new location is the same as the parent location, merge this stock back in the parent""" diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index da958c67efe..ccc14c8804f 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -968,7 +968,7 @@ class ReturnStockItemSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = ['location', 'note'] + fields = ['location', 'status', 'notes'] location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), @@ -979,6 +979,15 @@ class Meta: help_text=_('Destination location for returned item'), ) + status = serializers.ChoiceField( + choices=stock.status_codes.StockStatus.items(), + default=None, + label=_('Status'), + help_text=_('Stock item status code'), + required=False, + allow_blank=True, + ) + notes = serializers.CharField( label=_('Notes'), help_text=_('Add transaction note (optional)'), @@ -994,9 +1003,13 @@ def save(self): data = self.validated_data location = data['location'] - notes = data.get('notes', '') - item.return_from_customer(location, user=request.user, notes=notes) + item.return_from_customer( + location, + user=request.user, + notes=data.get('notes', ''), + status=data.get('status', None), + ) class StockChangeStatusSerializer(serializers.Serializer): diff --git a/src/frontend/src/forms/ReturnOrderForms.tsx b/src/frontend/src/forms/ReturnOrderForms.tsx index 805c94b3ec4..4fe37b66fdb 100644 --- a/src/frontend/src/forms/ReturnOrderForms.tsx +++ b/src/frontend/src/forms/ReturnOrderForms.tsx @@ -4,6 +4,7 @@ import { IconAddressBook, IconUser, IconUsers } from '@tabler/icons-react'; import { useMemo } from 'react'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; +import { StandaloneField } from '../components/forms/StandaloneField'; import type { ApiFormAdjustFilterType, ApiFormFieldSet @@ -11,8 +12,10 @@ import type { import type { TableFieldRowProps } from '../components/forms/fields/TableField'; import { Thumbnail } from '../components/images/Thumbnail'; import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { ModelType } from '../enums/ModelType'; import { useCreateApiFormModal } from '../hooks/UseForm'; import { apiUrl } from '../states/ApiState'; +import { StatusFilterOptions } from '../tables/Filter'; export function useReturnOrderFields({ duplicateOrderId @@ -133,6 +136,17 @@ function ReturnOrderLineItemFormRow({ props: TableFieldRowProps; record: any; }>) { + const statusOptions = useMemo(() => { + return ( + StatusFilterOptions(ModelType.stockitem)()?.map((choice) => { + return { + value: choice.value, + display_name: choice.label + }; + }) ?? [] + ); + }, []); + return ( <> @@ -146,7 +160,21 @@ function ReturnOrderLineItemFormRow({
{record.part_detail.name}
- {record.item_detail.serial} + # {record.item_detail.serial} + + { + props.changeFn(props.idx, 'status', value); + } + }} + defaultValue={record.item_detail?.status} + error={props.rowErrors?.status?.message} + /> + props.removeFn(props.idx)} /> @@ -181,7 +209,7 @@ export function useReceiveReturnOrderLineItems( /> ); }, - headers: [t`Part`, t`Serial Number`] + headers: [t`Part`, t`Stock Item`, t`Status`] }, location: { filters: { diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 1c0c7e684d8..db3eecab2c6 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -639,10 +639,12 @@ export default function StockDetail() { ), fields: { location: {}, + status: {}, notes: {} }, initialData: { - location: stockitem.location ?? stockitem.part_detail?.default_location + location: stockitem.location ?? stockitem.part_detail?.default_location, + status: stockitem.status_custom_key ?? stockitem.status }, successMessage: t`Item returned to stock`, onFormSuccess: () => { diff --git a/src/frontend/src/tables/sales/ReturnOrderTable.tsx b/src/frontend/src/tables/sales/ReturnOrderTable.tsx index d086b7ad6f8..6a5f6f96944 100644 --- a/src/frontend/src/tables/sales/ReturnOrderTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderTable.tsx @@ -152,6 +152,9 @@ export function ReturnOrderTable({ url: ApiEndpoints.return_order_list, title: t`Add Return Order`, fields: returnOrderFields, + initialData: { + customer: customerId + }, follow: true, modelType: ModelType.returnorder });