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:
@ -1,12 +1,17 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""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 = """
|
||||||
|
|
||||||
|
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
|
v357 -> 2025-06-25 : https://github.com/inventree/InvenTree/pull/9856
|
||||||
- Adds "units" field to PluginSetting API endpoints
|
- Adds "units" field to PluginSetting API endpoints
|
||||||
|
|
||||||
|
@ -8,13 +8,15 @@ from django.urls import include, path
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers, status
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
import build.serializers
|
import build.serializers
|
||||||
import common.models
|
import common.models
|
||||||
import part.models as part_models
|
import part.models as part_models
|
||||||
|
import stock.serializers
|
||||||
from build.models import Build, BuildItem, BuildLine
|
from build.models import Build, BuildItem, BuildLine
|
||||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||||
from data_exporter.mixins import DataExportViewMixin
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
@ -647,6 +649,20 @@ class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
|
|||||||
|
|
||||||
serializer_class = build.serializers.BuildOutputCreateSerializer
|
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):
|
class BuildOutputScrap(BuildOrderContextMixin, CreateAPI):
|
||||||
"""API endpoint for scrapping build output(s)."""
|
"""API endpoint for scrapping build output(s)."""
|
||||||
@ -705,7 +721,7 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
|
|||||||
- Only looks at 'untracked' parts
|
- Only looks at 'untracked' parts
|
||||||
- If stock exists in a single location, easy!
|
- If stock exists in a single location, easy!
|
||||||
- If user decides that stock items are "fungible", allocate against multiple stock items
|
- 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()
|
queryset = Build.objects.none()
|
||||||
|
@ -860,10 +860,10 @@ class Build(
|
|||||||
allocations.delete()
|
allocations.delete()
|
||||||
|
|
||||||
@transaction.atomic
|
@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.
|
"""Create a new build output against this BuildOrder.
|
||||||
|
|
||||||
Args:
|
Arguments:
|
||||||
quantity: The quantity of the item to produce
|
quantity: The quantity of the item to produce
|
||||||
|
|
||||||
Kwargs:
|
Kwargs:
|
||||||
@ -871,6 +871,9 @@ class Build(
|
|||||||
serials: Serial numbers
|
serials: Serial numbers
|
||||||
location: Override location
|
location: Override location
|
||||||
auto_allocate: Automatically allocate stock with matching serial numbers
|
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()
|
trackable_parts = self.part.get_trackable_parts()
|
||||||
|
|
||||||
@ -895,6 +898,8 @@ class Build(
|
|||||||
'serials': _('Serial numbers must be provided for trackable parts')
|
'serials': _('Serial numbers must be provided for trackable parts')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
|
||||||
# We are generating multiple serialized outputs
|
# We are generating multiple serialized outputs
|
||||||
if serials:
|
if serials:
|
||||||
"""Create multiple build outputs with a single quantity of 1."""
|
"""Create multiple build outputs with a single quantity of 1."""
|
||||||
@ -989,10 +994,14 @@ class Build(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
outputs = [output]
|
||||||
|
|
||||||
if self.status == BuildStatus.PENDING:
|
if self.status == BuildStatus.PENDING:
|
||||||
self.status = BuildStatus.PRODUCTION.value
|
self.status = BuildStatus.PRODUCTION.value
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
return outputs
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def delete_output(self, output):
|
def delete_output(self, output):
|
||||||
"""Remove a build output from the database.
|
"""Remove a build output from the database.
|
||||||
|
@ -421,7 +421,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
build = self.get_build()
|
build = self.get_build()
|
||||||
|
|
||||||
build.create_build_output(
|
return build.create_build_output(
|
||||||
data['quantity'],
|
data['quantity'],
|
||||||
serials=self.serials,
|
serials=self.serials,
|
||||||
batch=data.get('batch_code', ''),
|
batch=data.get('batch_code', ''),
|
||||||
|
@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
from drf_spectacular.types import OpenApiTypes
|
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 import status
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -120,6 +120,21 @@ class StockItemSerialize(StockItemContextMixin, CreateAPI):
|
|||||||
|
|
||||||
serializer_class = StockSerializers.SerializeStockItemSerializer
|
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):
|
class StockItemInstall(StockItemContextMixin, CreateAPI):
|
||||||
"""API endpoint for installing a particular stock item into this stock item.
|
"""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
|
# Check if a set of serial numbers was provided
|
||||||
serial_numbers = data.pop('serial_numbers', '')
|
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
|
# Check if the supplier_part has a package size defined, which is not 1
|
||||||
if supplier_part_id := data.get('supplier_part', None):
|
if supplier_part_id := data.get('supplier_part', None):
|
||||||
try:
|
try:
|
||||||
@ -1097,10 +1116,6 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
|
|||||||
serializer = self.get_serializer(data=data)
|
serializer = self.get_serializer(data=data)
|
||||||
serializer.is_valid(raise_exception=True)
|
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
|
# Extract location information
|
||||||
location = serializer.validated_data.get('location', None)
|
location = serializer.validated_data.get('location', None)
|
||||||
|
|
||||||
@ -1127,7 +1142,11 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
|
|||||||
|
|
||||||
StockItemTracking.objects.bulk_create(tracking)
|
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:
|
else:
|
||||||
# Create a single StockItem object
|
# Create a single StockItem object
|
||||||
|
@ -1699,23 +1699,33 @@ class StockItem(
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
@transaction.atomic
|
@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.
|
"""Split this stock item into unique serial numbers.
|
||||||
|
|
||||||
- Quantity can be less than or equal to the quantity of the stock item
|
- Quantity can be less than or equal to the quantity of the stock item
|
||||||
- Number of serial numbers must match the quantity
|
- Number of serial numbers must match the quantity
|
||||||
- Provided serial numbers must not already be in use
|
- Provided serial numbers must not already be in use
|
||||||
|
|
||||||
Args:
|
Arguments:
|
||||||
quantity: Number of items to serialize (integer)
|
quantity: Number of items to serialize (integer)
|
||||||
serials: List of serial numbers
|
serials: List of serial numbers
|
||||||
user: User object associated with action
|
user: User object associated with action
|
||||||
notes: Optional notes for tracking
|
notes: Optional notes for tracking
|
||||||
location: If specified, serialized items will be placed in the given location
|
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!
|
# Cannot serialize stock that is already serialized!
|
||||||
if self.serialized:
|
if self.serialized:
|
||||||
return
|
return None
|
||||||
|
|
||||||
if not self.part.trackable:
|
if not self.part.trackable:
|
||||||
raise ValidationError({'part': _('Part is not set as 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'
|
stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id, group='stock'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def copyHistoryFrom(self, other):
|
def copyHistoryFrom(self, other):
|
||||||
"""Copy stock history from another StockItem."""
|
"""Copy stock history from another StockItem."""
|
||||||
|
@ -790,8 +790,12 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self) -> list[StockItem]:
|
||||||
"""Serialize stock item."""
|
"""Serialize the provided StockItem.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of StockItem objects that were created as a result of the serialization.
|
||||||
|
"""
|
||||||
item = self.context['item']
|
item = self.context['item']
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
user = request.user if request else None
|
user = request.user if request else None
|
||||||
@ -805,12 +809,15 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
|||||||
part=item.part,
|
part=item.part,
|
||||||
)
|
)
|
||||||
|
|
||||||
item.serializeStock(
|
return (
|
||||||
data['quantity'],
|
item.serializeStock(
|
||||||
serials,
|
data['quantity'],
|
||||||
user,
|
serials,
|
||||||
notes=data.get('notes', ''),
|
user,
|
||||||
location=data['destination'],
|
notes=data.get('notes', ''),
|
||||||
|
location=data['destination'],
|
||||||
|
)
|
||||||
|
or []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1142,7 +1142,7 @@ class CustomStockItemStatusTest(StockAPITestCase):
|
|||||||
self.assertEqual(response.data['status'], self.status2.logical_key)
|
self.assertEqual(response.data['status'], self.status2.logical_key)
|
||||||
self.assertEqual(response.data['status_custom_key'], self.status2.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(
|
response = self.patch(
|
||||||
reverse('api-stock-detail', kwargs={'pk': pk}),
|
reverse('api-stock-detail', kwargs={'pk': pk}),
|
||||||
{'status': self.status.logical_key},
|
{'status': self.status.logical_key},
|
||||||
@ -1399,22 +1399,20 @@ class StockItemTest(StockAPITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
data = response.data
|
data = response.data
|
||||||
|
self.assertEqual(len(data), 10)
|
||||||
self.assertEqual(data['quantity'], 10)
|
serials = [item['serial'] for item in data]
|
||||||
sn = data['serial_numbers']
|
|
||||||
|
|
||||||
# Check that each serial number was created
|
# Check that each serial number was created
|
||||||
for i in range(1, 11):
|
for i in range(1, 11):
|
||||||
self.assertIn(str(i), sn)
|
self.assertIn(str(i), serials)
|
||||||
|
|
||||||
# Check the unique stock item has been created
|
# Check the unique stock item has been created
|
||||||
|
for item in data:
|
||||||
item = StockItem.objects.get(part=trackable_part, serial=str(i))
|
item = StockItem.objects.get(pk=item['pk'])
|
||||||
|
|
||||||
# Item location should have been set automatically
|
# Item location should have been set automatically
|
||||||
self.assertIsNotNone(item.location)
|
self.assertIsNotNone(item.location)
|
||||||
|
self.assertIn(item.serial, serials)
|
||||||
self.assertEqual(str(i), item.serial)
|
|
||||||
|
|
||||||
# There now should be 10 unique stock entries for this part
|
# There now should be 10 unique stock entries for this part
|
||||||
self.assertEqual(trackable_part.stock_entries().count(), 10)
|
self.assertEqual(trackable_part.stock_entries().count(), 10)
|
||||||
|
@ -33,6 +33,7 @@ import { ModelType } from '@lib/enums/ModelType';
|
|||||||
import { UserRoles } from '@lib/enums/Roles';
|
import { UserRoles } from '@lib/enums/Roles';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
|
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
|
||||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||||
import AdminButton from '../../components/buttons/AdminButton';
|
import AdminButton from '../../components/buttons/AdminButton';
|
||||||
@ -682,7 +683,23 @@ export default function StockDetail() {
|
|||||||
...duplicateStockData
|
...duplicateStockData
|
||||||
},
|
},
|
||||||
follow: true,
|
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(() => {
|
const preDeleteContent = useMemo(() => {
|
||||||
|
@ -563,8 +563,7 @@ export function StockItemTable({
|
|||||||
const can_delete_stock = user.hasDeleteRole(UserRoles.stock);
|
const can_delete_stock = user.hasDeleteRole(UserRoles.stock);
|
||||||
const can_add_stock = user.hasAddRole(UserRoles.stock);
|
const can_add_stock = user.hasAddRole(UserRoles.stock);
|
||||||
const can_add_stocktake = user.hasAddRole(UserRoles.stocktake);
|
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 [
|
return [
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
key='stock-actions'
|
key='stock-actions'
|
||||||
|
Reference in New Issue
Block a user