mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-05 19:10:54 +00:00
Allow bulk edit of stock batch code (#11499)
* Allow bulk edit of stock batch code Closes https://github.com/inventree/InvenTree/issues/10817 * Bump API version * Add unit test
This commit is contained in:
@@ -594,17 +594,24 @@ class BulkUpdateMixin(BulkOperationMixin):
|
|||||||
|
|
||||||
n = queryset.count()
|
n = queryset.count()
|
||||||
|
|
||||||
|
instance_data = []
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Perform object update
|
# Perform object update
|
||||||
# Note that we do not perform a bulk-update operation here,
|
# Note that we do not perform a bulk-update operation here,
|
||||||
# as we want to trigger any custom post_save methods on the model
|
# as we want to trigger any custom post_save methods on the model
|
||||||
|
|
||||||
|
# Run validation first
|
||||||
for instance in queryset:
|
for instance in queryset:
|
||||||
serializer = self.get_serializer(instance, data=data, partial=True)
|
serializer = self.get_serializer(instance, data=data, partial=True)
|
||||||
|
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
return Response({'success': f'Updated {n} items'}, status=200)
|
instance_data.append(serializer.data)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{'success': f'Updated {n} items', 'items': instance_data}, status=200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ParameterListMixin:
|
class ParameterListMixin:
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 462
|
INVENTREE_API_VERSION = 463
|
||||||
"""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 = """
|
||||||
|
|
||||||
|
v463 -> 2026-03-12 : https://github.com/inventree/InvenTree/pull/11499
|
||||||
|
- Allow "bulk update" actions against StockItem endpoint
|
||||||
|
|
||||||
v462 -> 2026-03-12 : https://github.com/inventree/InvenTree/pull/11497
|
v462 -> 2026-03-12 : https://github.com/inventree/InvenTree/pull/11497
|
||||||
- Allow "ScheduledTask" API endpoint to be filtered by "name" field
|
- Allow "ScheduledTask" API endpoint to be filtered by "name" field
|
||||||
|
|
||||||
|
|||||||
@@ -1050,7 +1050,11 @@ class StockOutputOptions(OutputConfiguration):
|
|||||||
|
|
||||||
|
|
||||||
class StockList(
|
class StockList(
|
||||||
DataExportViewMixin, StockApiMixin, OutputOptionsMixin, ListCreateDestroyAPIView
|
DataExportViewMixin,
|
||||||
|
BulkUpdateMixin,
|
||||||
|
StockApiMixin,
|
||||||
|
OutputOptionsMixin,
|
||||||
|
ListCreateDestroyAPIView,
|
||||||
):
|
):
|
||||||
"""API endpoint for list view of Stock objects.
|
"""API endpoint for list view of Stock objects.
|
||||||
|
|
||||||
|
|||||||
@@ -2006,6 +2006,33 @@ class StockItemTest(StockAPITestCase):
|
|||||||
self.assertEqual(tracking.deltas['status'], StockStatus.OK.value)
|
self.assertEqual(tracking.deltas['status'], StockStatus.OK.value)
|
||||||
self.assertEqual(tracking.deltas['status_logical'], StockStatus.OK.value)
|
self.assertEqual(tracking.deltas['status_logical'], StockStatus.OK.value)
|
||||||
|
|
||||||
|
def test_bulk_batch_change(self):
|
||||||
|
"""Test that we can bulk-change batch code for a set of stock items."""
|
||||||
|
url = reverse('api-stock-list')
|
||||||
|
|
||||||
|
# Find the first 10 stock items
|
||||||
|
items = StockItem.objects.all()[:10]
|
||||||
|
self.assertEqual(len(items), 10)
|
||||||
|
|
||||||
|
response = self.patch(
|
||||||
|
url,
|
||||||
|
data={'items': [item.pk for item in items], 'batch': 'NEW-BATCH-CODE'},
|
||||||
|
max_query_count=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.data
|
||||||
|
|
||||||
|
self.assertEqual(data['success'], 'Updated 10 items')
|
||||||
|
self.assertEqual(len(data['items']), 10)
|
||||||
|
|
||||||
|
for item in data['items']:
|
||||||
|
self.assertEqual(item['batch'], 'NEW-BATCH-CODE')
|
||||||
|
|
||||||
|
# Check database items also
|
||||||
|
for item in items:
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(item.batch, 'NEW-BATCH-CODE')
|
||||||
|
|
||||||
|
|
||||||
class StocktakeTest(StockAPITestCase):
|
class StocktakeTest(StockAPITestCase):
|
||||||
"""Series of tests for the Stocktake API."""
|
"""Series of tests for the Stocktake API."""
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { RenderStockLocation } from '../components/render/Stock';
|
|||||||
import { InvenTreeIcon } from '../functions/icons';
|
import { InvenTreeIcon } from '../functions/icons';
|
||||||
import {
|
import {
|
||||||
useApiFormModal,
|
useApiFormModal,
|
||||||
|
useBulkEditApiFormModal,
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal
|
useDeleteApiFormModal
|
||||||
} from '../hooks/UseForm';
|
} from '../hooks/UseForm';
|
||||||
@@ -1375,6 +1376,38 @@ export function useChangeStockStatus(props: StockOperationProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useChangeStockBatchCode(props: StockOperationProps) {
|
||||||
|
// Return a common batch code value if all items share the same batch code, otherwise return undefined
|
||||||
|
const batchCode = useMemo(() => {
|
||||||
|
const batchCodeValues = new Set(
|
||||||
|
props.items?.filter((item) => item.batch).map((item) => item.batch)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (batchCodeValues.size === 1) {
|
||||||
|
return batchCodeValues.values().next().value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [props.items]);
|
||||||
|
|
||||||
|
return useBulkEditApiFormModal({
|
||||||
|
url: ApiEndpoints.stock_item_list,
|
||||||
|
items: props.items?.map((item: any) => item.pk) ?? [],
|
||||||
|
title: t`Change Batch Code`,
|
||||||
|
preFormContent: (
|
||||||
|
<Alert color='blue'>
|
||||||
|
{t`Change batch code for the selected stock items`}
|
||||||
|
</Alert>
|
||||||
|
),
|
||||||
|
fields: {
|
||||||
|
batch: {
|
||||||
|
value: batchCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFormSuccess: props.refresh
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useMergeStockItem(props: StockOperationProps) {
|
export function useMergeStockItem(props: StockOperationProps) {
|
||||||
return useStockOperationModal({
|
return useStockOperationModal({
|
||||||
...props,
|
...props,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ActionDropdown } from '../components/items/ActionDropdown';
|
|||||||
import {
|
import {
|
||||||
useAddStockItem,
|
useAddStockItem,
|
||||||
useAssignStockItem,
|
useAssignStockItem,
|
||||||
|
useChangeStockBatchCode,
|
||||||
useChangeStockStatus,
|
useChangeStockStatus,
|
||||||
useCountStockItem,
|
useCountStockItem,
|
||||||
useDeleteStockItem,
|
useDeleteStockItem,
|
||||||
@@ -26,6 +27,7 @@ interface StockAdjustActionProps {
|
|||||||
assign?: boolean;
|
assign?: boolean;
|
||||||
count?: boolean;
|
count?: boolean;
|
||||||
changeStatus?: boolean;
|
changeStatus?: boolean;
|
||||||
|
changeBatch?: boolean;
|
||||||
delete?: boolean;
|
delete?: boolean;
|
||||||
merge?: boolean;
|
merge?: boolean;
|
||||||
remove?: boolean;
|
remove?: boolean;
|
||||||
@@ -55,6 +57,7 @@ export function useStockAdjustActions(
|
|||||||
const addStock = useAddStockItem(props.formProps);
|
const addStock = useAddStockItem(props.formProps);
|
||||||
const assignStock = useAssignStockItem(props.formProps);
|
const assignStock = useAssignStockItem(props.formProps);
|
||||||
const changeStatus = useChangeStockStatus(props.formProps);
|
const changeStatus = useChangeStockStatus(props.formProps);
|
||||||
|
const changeBatch = useChangeStockBatchCode(props.formProps);
|
||||||
const countStock = useCountStockItem(props.formProps);
|
const countStock = useCountStockItem(props.formProps);
|
||||||
const deleteStock = useDeleteStockItem(props.formProps);
|
const deleteStock = useDeleteStockItem(props.formProps);
|
||||||
const mergeStock = useMergeStockItem(props.formProps);
|
const mergeStock = useMergeStockItem(props.formProps);
|
||||||
@@ -74,6 +77,7 @@ export function useStockAdjustActions(
|
|||||||
props.assign != false && modals.push(assignStock);
|
props.assign != false && modals.push(assignStock);
|
||||||
props.count != false && modals.push(countStock);
|
props.count != false && modals.push(countStock);
|
||||||
props.changeStatus != false && modals.push(changeStatus);
|
props.changeStatus != false && modals.push(changeStatus);
|
||||||
|
props.changeBatch != false && modals.push(changeBatch);
|
||||||
props.merge != false && modals.push(mergeStock);
|
props.merge != false && modals.push(mergeStock);
|
||||||
props.remove != false && modals.push(removeStock);
|
props.remove != false && modals.push(removeStock);
|
||||||
props.transfer != false && modals.push(transferStock);
|
props.transfer != false && modals.push(transferStock);
|
||||||
@@ -153,6 +157,16 @@ export function useStockAdjustActions(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
props.changeBatch != false &&
|
||||||
|
menuActions.push({
|
||||||
|
name: t`Change Batch Code`,
|
||||||
|
icon: <InvenTreeIcon icon='batch' iconProps={{ color: 'blue' }} />,
|
||||||
|
tooltip: t`Change batch code of selected stock items`,
|
||||||
|
onClick: () => {
|
||||||
|
changeBatch.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
props.assign != false &&
|
props.assign != false &&
|
||||||
menuActions.push({
|
menuActions.push({
|
||||||
name: t`Assign Stock`,
|
name: t`Assign Stock`,
|
||||||
|
|||||||
@@ -1118,6 +1118,7 @@ export default function PartDetail() {
|
|||||||
const stockAdjustActions = useStockAdjustActions({
|
const stockAdjustActions = useStockAdjustActions({
|
||||||
formProps: stockOperationProps,
|
formProps: stockOperationProps,
|
||||||
merge: false,
|
merge: false,
|
||||||
|
changeBatch: false,
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export default function Stock() {
|
|||||||
const stockAdjustActions = useStockAdjustActions({
|
const stockAdjustActions = useStockAdjustActions({
|
||||||
formProps: stockOperationProps,
|
formProps: stockOperationProps,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
changeBatch: false,
|
||||||
delete: false,
|
delete: false,
|
||||||
merge: false,
|
merge: false,
|
||||||
assign: false
|
assign: false
|
||||||
|
|||||||
@@ -754,6 +754,7 @@ export default function StockDetail() {
|
|||||||
const stockAdjustActions = useStockAdjustActions({
|
const stockAdjustActions = useStockAdjustActions({
|
||||||
formProps: stockOperationProps,
|
formProps: stockOperationProps,
|
||||||
delete: false,
|
delete: false,
|
||||||
|
changeBatch: false,
|
||||||
assign: !!stockitem.in_stock && stockitem.part_detail?.salable,
|
assign: !!stockitem.in_stock && stockitem.part_detail?.salable,
|
||||||
return: !!stockitem.consumed_by || !!stockitem.customer,
|
return: !!stockitem.consumed_by || !!stockitem.customer,
|
||||||
merge: false
|
merge: false
|
||||||
|
|||||||
@@ -428,7 +428,8 @@ export function StockItemTable({
|
|||||||
|
|
||||||
const stockAdjustActions = useStockAdjustActions({
|
const stockAdjustActions = useStockAdjustActions({
|
||||||
formProps: stockOperationProps,
|
formProps: stockOperationProps,
|
||||||
return: allowReturn
|
return: allowReturn,
|
||||||
|
changeBatch: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user