diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 79173c6ab4..249091c9b0 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -594,17 +594,24 @@ class BulkUpdateMixin(BulkOperationMixin): n = queryset.count() + instance_data = [] + with transaction.atomic(): # Perform object update # Note that we do not perform a bulk-update operation here, # as we want to trigger any custom post_save methods on the model + + # Run validation first for instance in queryset: serializer = self.get_serializer(instance, data=data, partial=True) - serializer.is_valid(raise_exception=True) 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: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5b266f89e8..9a0cd731cd 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # 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.""" 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 - Allow "ScheduledTask" API endpoint to be filtered by "name" field diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index bef906f6e5..522bad77e2 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -1050,7 +1050,11 @@ class StockOutputOptions(OutputConfiguration): class StockList( - DataExportViewMixin, StockApiMixin, OutputOptionsMixin, ListCreateDestroyAPIView + DataExportViewMixin, + BulkUpdateMixin, + StockApiMixin, + OutputOptionsMixin, + ListCreateDestroyAPIView, ): """API endpoint for list view of Stock objects. diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index cbaea852f9..c57a8e6de0 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2006,6 +2006,33 @@ class StockItemTest(StockAPITestCase): self.assertEqual(tracking.deltas['status'], 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): """Series of tests for the Stocktake API.""" diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 67bbc51327..3ab80b7543 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -51,6 +51,7 @@ import { RenderStockLocation } from '../components/render/Stock'; import { InvenTreeIcon } from '../functions/icons'; import { useApiFormModal, + useBulkEditApiFormModal, useCreateApiFormModal, useDeleteApiFormModal } 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: ( + + {t`Change batch code for the selected stock items`} + + ), + fields: { + batch: { + value: batchCode + } + }, + onFormSuccess: props.refresh + }); +} + export function useMergeStockItem(props: StockOperationProps) { return useStockOperationModal({ ...props, diff --git a/src/frontend/src/hooks/UseStockAdjustActions.tsx b/src/frontend/src/hooks/UseStockAdjustActions.tsx index f0450bf0b3..9fe864a25a 100644 --- a/src/frontend/src/hooks/UseStockAdjustActions.tsx +++ b/src/frontend/src/hooks/UseStockAdjustActions.tsx @@ -8,6 +8,7 @@ import { ActionDropdown } from '../components/items/ActionDropdown'; import { useAddStockItem, useAssignStockItem, + useChangeStockBatchCode, useChangeStockStatus, useCountStockItem, useDeleteStockItem, @@ -26,6 +27,7 @@ interface StockAdjustActionProps { assign?: boolean; count?: boolean; changeStatus?: boolean; + changeBatch?: boolean; delete?: boolean; merge?: boolean; remove?: boolean; @@ -55,6 +57,7 @@ export function useStockAdjustActions( const addStock = useAddStockItem(props.formProps); const assignStock = useAssignStockItem(props.formProps); const changeStatus = useChangeStockStatus(props.formProps); + const changeBatch = useChangeStockBatchCode(props.formProps); const countStock = useCountStockItem(props.formProps); const deleteStock = useDeleteStockItem(props.formProps); const mergeStock = useMergeStockItem(props.formProps); @@ -74,6 +77,7 @@ export function useStockAdjustActions( props.assign != false && modals.push(assignStock); props.count != false && modals.push(countStock); props.changeStatus != false && modals.push(changeStatus); + props.changeBatch != false && modals.push(changeBatch); props.merge != false && modals.push(mergeStock); props.remove != false && modals.push(removeStock); props.transfer != false && modals.push(transferStock); @@ -153,6 +157,16 @@ export function useStockAdjustActions( } }); + props.changeBatch != false && + menuActions.push({ + name: t`Change Batch Code`, + icon: , + tooltip: t`Change batch code of selected stock items`, + onClick: () => { + changeBatch.open(); + } + }); + props.assign != false && menuActions.push({ name: t`Assign Stock`, diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 22747010dd..7551ba964f 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -1118,6 +1118,7 @@ export default function PartDetail() { const stockAdjustActions = useStockAdjustActions({ formProps: stockOperationProps, merge: false, + changeBatch: false, enabled: true }); diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 1cdd66d5b5..8d25eefbed 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -307,6 +307,7 @@ export default function Stock() { const stockAdjustActions = useStockAdjustActions({ formProps: stockOperationProps, enabled: true, + changeBatch: false, delete: false, merge: false, assign: false diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 6b39b83964..c508083616 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -754,6 +754,7 @@ export default function StockDetail() { const stockAdjustActions = useStockAdjustActions({ formProps: stockOperationProps, delete: false, + changeBatch: false, assign: !!stockitem.in_stock && stockitem.part_detail?.salable, return: !!stockitem.consumed_by || !!stockitem.customer, merge: false diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index de0d125548..04d37dab84 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -428,7 +428,8 @@ export function StockItemTable({ const stockAdjustActions = useStockAdjustActions({ formProps: stockOperationProps, - return: allowReturn + return: allowReturn, + changeBatch: true }); const tableActions = useMemo(() => {