From be99b645ad2d45228db4ec0bfc44f488c826b9e7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 26 Jun 2025 00:43:42 +1000 Subject: [PATCH] [API] Return stock item list when creating multiple items (#9857) * Return stock item information when serializing an existing item * Handle stock item creation * Commonize response * Provide build items in response * Formalize returned data type * Fix unit test --- .../InvenTree/InvenTree/api_version.py | 7 ++++- src/backend/InvenTree/build/api.py | 22 +++++++++++-- src/backend/InvenTree/build/models.py | 13 ++++++-- src/backend/InvenTree/build/serializers.py | 2 +- src/backend/InvenTree/stock/api.py | 31 +++++++++++++++---- src/backend/InvenTree/stock/models.py | 18 +++++++++-- src/backend/InvenTree/stock/serializers.py | 23 +++++++++----- src/backend/InvenTree/stock/test_api.py | 16 +++++----- src/frontend/src/pages/stock/StockDetail.tsx | 19 +++++++++++- .../src/tables/stock/StockItemTable.tsx | 3 +- 10 files changed, 118 insertions(+), 36 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 149a97adb9..55c94fdc04 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 357 +INVENTREE_API_VERSION = 358 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v358 -> 2025-06-25 : https://github.com/inventree/InvenTree/pull/9857 + - Provide list of generated stock items against "StockItemSerialize" API endpoint + - Provide list of generated stock items against "StockList" API endpoint + - Provide list of generated stock items against "BuildOutputCreate" API endpoint + v357 -> 2025-06-25 : https://github.com/inventree/InvenTree/pull/9856 - Adds "units" field to PluginSetting API endpoints diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 55e4bb0980..3bb1636ca5 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -8,13 +8,15 @@ from django.urls import include, path from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as rest_filters -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers +from drf_spectacular.utils import extend_schema, extend_schema_field +from rest_framework import serializers, status from rest_framework.exceptions import ValidationError +from rest_framework.response import Response import build.serializers import common.models import part.models as part_models +import stock.serializers from build.models import Build, BuildItem, BuildLine from build.status_codes import BuildStatus, BuildStatusGroups from data_exporter.mixins import DataExportViewMixin @@ -647,6 +649,20 @@ class BuildOutputCreate(BuildOrderContextMixin, CreateAPI): serializer_class = build.serializers.BuildOutputCreateSerializer + @extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)}) + def create(self, request, *args, **kwargs): + """Override the create method to handle the creation of build outputs.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Create the build output(s) + outputs = serializer.save() + + response = stock.serializers.StockItemSerializer(outputs, many=True) + + # Return the created outputs + return Response(response.data, status=status.HTTP_201_CREATED) + class BuildOutputScrap(BuildOrderContextMixin, CreateAPI): """API endpoint for scrapping build output(s).""" @@ -705,7 +721,7 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI): - Only looks at 'untracked' parts - If stock exists in a single location, easy! - If user decides that stock items are "fungible", allocate against multiple stock items - - If the user wants to, allocate substite parts if the primary parts are not available. + - If the user wants to, allocate substitute parts if the primary parts are not available. """ queryset = Build.objects.none() diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index cf5b848cb2..1a81e1cb03 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -860,10 +860,10 @@ class Build( allocations.delete() @transaction.atomic - def create_build_output(self, quantity, **kwargs): + def create_build_output(self, quantity, **kwargs) -> list[stock.models.StockItem]: """Create a new build output against this BuildOrder. - Args: + Arguments: quantity: The quantity of the item to produce Kwargs: @@ -871,6 +871,9 @@ class Build( serials: Serial numbers location: Override location auto_allocate: Automatically allocate stock with matching serial numbers + + Returns: + A list of the created output (StockItem) objects. """ trackable_parts = self.part.get_trackable_parts() @@ -895,6 +898,8 @@ class Build( 'serials': _('Serial numbers must be provided for trackable parts') }) + outputs = [] + # We are generating multiple serialized outputs if serials: """Create multiple build outputs with a single quantity of 1.""" @@ -989,10 +994,14 @@ class Build( }, ) + outputs = [output] + if self.status == BuildStatus.PENDING: self.status = BuildStatus.PRODUCTION.value self.save() + return outputs + @transaction.atomic def delete_output(self, output): """Remove a build output from the database. diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 9d97998dde..14c36fa773 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -421,7 +421,7 @@ class BuildOutputCreateSerializer(serializers.Serializer): request = self.context.get('request') build = self.get_build() - build.create_build_output( + return build.create_build_output( data['quantity'], serials=self.serials, batch=data.get('batch_code', ''), diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 74d0b75f39..175519b093 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as rest_filters from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field +from drf_spectacular.utils import extend_schema, extend_schema_field from rest_framework import status from rest_framework.generics import GenericAPIView from rest_framework.response import Response @@ -120,6 +120,21 @@ class StockItemSerialize(StockItemContextMixin, CreateAPI): serializer_class = StockSerializers.SerializeStockItemSerializer + @extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)}) + def create(self, request, *args, **kwargs): + """Serialize the provided StockItem.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Perform the actual serialization step + items = serializer.save() + + response = StockSerializers.StockItemSerializer( + items, many=True, context=self.get_serializer_context() + ) + + return Response(response.data, status=status.HTTP_201_CREATED) + class StockItemInstall(StockItemContextMixin, CreateAPI): """API endpoint for installing a particular stock item into this stock item. @@ -1010,6 +1025,10 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView): # Check if a set of serial numbers was provided serial_numbers = data.pop('serial_numbers', '') + # Exclude 'serial' from submitted data + # We use 'serial_numbers' for item creation + data.pop('serial', None) + # Check if the supplier_part has a package size defined, which is not 1 if supplier_part_id := data.get('supplier_part', None): try: @@ -1097,10 +1116,6 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView): serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) - # Exclude 'serial' from submitted data - # We use 'serial_numbers' for item creation - serializer.validated_data.pop('serial', None) - # Extract location information location = serializer.validated_data.get('location', None) @@ -1127,7 +1142,11 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView): StockItemTracking.objects.bulk_create(tracking) - response_data = {'quantity': quantity, 'serial_numbers': serials} + response = StockSerializers.StockItemSerializer( + items, many=True, context=self.get_serializer_context() + ) + + response_data = response.data else: # Create a single StockItem object diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 6e3a4d1d0c..9cbacada67 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -1699,23 +1699,33 @@ class StockItem( return entry @transaction.atomic - def serializeStock(self, quantity, serials, user, notes='', location=None): + def serializeStock( + self, + quantity: int, + serials: list[str], + user: User, + notes: str = '', + location: Optional[StockLocation] = None, + ): """Split this stock item into unique serial numbers. - Quantity can be less than or equal to the quantity of the stock item - Number of serial numbers must match the quantity - Provided serial numbers must not already be in use - Args: + Arguments: quantity: Number of items to serialize (integer) serials: List of serial numbers user: User object associated with action notes: Optional notes for tracking location: If specified, serialized items will be placed in the given location + + Returns: + List of newly created StockItem objects, each with a unique serial number. """ # Cannot serialize stock that is already serialized! if self.serialized: - return + return None if not self.part.trackable: raise ValidationError({'part': _('Part is not set as trackable')}) @@ -1804,6 +1814,8 @@ class StockItem( stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id, group='stock' ) + return items + @transaction.atomic def copyHistoryFrom(self, other): """Copy stock history from another StockItem.""" diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index a59895ebbf..58c1d0a22e 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -790,8 +790,12 @@ class SerializeStockItemSerializer(serializers.Serializer): return data - def save(self): - """Serialize stock item.""" + def save(self) -> list[StockItem]: + """Serialize the provided StockItem. + + Returns: + A list of StockItem objects that were created as a result of the serialization. + """ item = self.context['item'] request = self.context.get('request') user = request.user if request else None @@ -805,12 +809,15 @@ class SerializeStockItemSerializer(serializers.Serializer): part=item.part, ) - item.serializeStock( - data['quantity'], - serials, - user, - notes=data.get('notes', ''), - location=data['destination'], + return ( + item.serializeStock( + data['quantity'], + serials, + user, + notes=data.get('notes', ''), + location=data['destination'], + ) + or [] ) diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index d579edb78f..a200c199a4 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -1142,7 +1142,7 @@ class CustomStockItemStatusTest(StockAPITestCase): self.assertEqual(response.data['status'], self.status2.logical_key) self.assertEqual(response.data['status_custom_key'], self.status2.key) - # Try if status_custom_key is rewrite with status bying set + # Try with custom status code response = self.patch( reverse('api-stock-detail', kwargs={'pk': pk}), {'status': self.status.logical_key}, @@ -1399,22 +1399,20 @@ class StockItemTest(StockAPITestCase): ) data = response.data - - self.assertEqual(data['quantity'], 10) - sn = data['serial_numbers'] + self.assertEqual(len(data), 10) + serials = [item['serial'] for item in data] # Check that each serial number was created for i in range(1, 11): - self.assertIn(str(i), sn) + self.assertIn(str(i), serials) # Check the unique stock item has been created - - item = StockItem.objects.get(part=trackable_part, serial=str(i)) + for item in data: + item = StockItem.objects.get(pk=item['pk']) # Item location should have been set automatically self.assertIsNotNone(item.location) - - self.assertEqual(str(i), item.serial) + self.assertIn(item.serial, serials) # There now should be 10 unique stock entries for this part self.assertEqual(trackable_part.stock_entries().count(), 10) diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 140a644b60..e2c997cf75 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -33,6 +33,7 @@ import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; import { getDetailUrl } from '@lib/functions/Navigation'; +import { notifications } from '@mantine/notifications'; import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog'; import { ActionButton } from '../../components/buttons/ActionButton'; import AdminButton from '../../components/buttons/AdminButton'; @@ -682,7 +683,23 @@ export default function StockDetail() { ...duplicateStockData }, follow: true, - modelType: ModelType.stockitem + successMessage: null, + modelType: ModelType.stockitem, + onFormSuccess: (data) => { + // Handle case where multiple stock items are created + if (Array.isArray(data) && data.length > 0) { + if (data.length == 1) { + navigate(getDetailUrl(ModelType.stockitem, data[0]?.pk)); + } else { + const n: number = data.length; + notifications.show({ + title: t`Items Created`, + message: t`Created ${n} stock items`, + color: 'green' + }); + } + } + } }); const preDeleteContent = useMemo(() => { diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 9d2479811d..8674a318aa 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -563,8 +563,7 @@ export function StockItemTable({ const can_delete_stock = user.hasDeleteRole(UserRoles.stock); const can_add_stock = user.hasAddRole(UserRoles.stock); const can_add_stocktake = user.hasAddRole(UserRoles.stocktake); - const can_add_order = user.hasAddRole(UserRoles.purchase_order); - const can_change_order = user.hasChangeRole(UserRoles.purchase_order); + return [