2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 11:10:54 +00:00

[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
This commit is contained in:
Oliver
2025-06-26 00:43:42 +10:00
committed by GitHub
parent 6811132e30
commit be99b645ad
10 changed files with 118 additions and 36 deletions

View File

@ -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

View File

@ -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()

View File

@ -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.

View File

@ -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', ''),

View File

@ -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

View File

@ -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."""

View File

@ -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,6 +809,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
part=item.part,
)
return (
item.serializeStock(
data['quantity'],
serials,
@ -812,6 +817,8 @@ class SerializeStockItemSerializer(serializers.Serializer):
notes=data.get('notes', ''),
location=data['destination'],
)
or []
)
class InstallStockItemSerializer(serializers.Serializer):

View File

@ -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)

View File

@ -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(() => {

View File

@ -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 [
<ActionDropdown
key='stock-actions'