From a5f2273e141519e3711821953c9de78fac1c904c Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 27 Sep 2024 08:29:40 +1000 Subject: [PATCH] [PUI] Serialize Stock (#8192) * Add PUI form to serialize existing stock item * Remove debug statement * Ensure that stock item trees are rebuilt correctly after serialization - No idea how this has not been detected previously * Add unit test to ensure child_items annotation works as expected * Add link to parent item in stock detail page * Refactor to use new placeholder hook --- src/backend/InvenTree/stock/models.py | 9 ++- src/backend/InvenTree/stock/test_api.py | 40 ++++++++++++ src/frontend/src/enums/ApiEndpoints.tsx | 15 +++-- src/frontend/src/forms/StockForms.tsx | 25 ++++++++ src/frontend/src/hooks/UseInstance.tsx | 2 +- src/frontend/src/pages/stock/StockDetail.tsx | 65 ++++++++++++++++++-- 6 files changed, 143 insertions(+), 13 deletions(-) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 75d94e38ba..58742c8a76 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -1572,6 +1572,13 @@ class StockItem( # Remove the equivalent number of items self.take_stock(quantity, user, notes=notes) + # Rebuild the stock tree + try: + StockItem.objects.partial_rebuild(tree_id=self.tree_id) + except Exception: + logger.warning('Failed to rebuild stock tree during serializeStock') + StockItem.objects.rebuild() + @transaction.atomic def copyHistoryFrom(self, other): """Copy stock history from another StockItem.""" @@ -1806,7 +1813,7 @@ class StockItem( for tree_id in tree_ids: StockItem.objects.partial_rebuild(tree_id=tree_id) except Exception: - logger.warning('Rebuilding entire StockItem tree') + logger.warning('Rebuilding entire StockItem tree during merge_stock_items') StockItem.objects.rebuild() @transaction.atomic diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 9cce95ec4b..3b6971730d 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -925,6 +925,46 @@ class StockItemListTest(StockAPITestCase): self.list_url, {'location_detail': True, 'tests': True}, max_query_count=35 ) + def test_child_items(self): + """Test that the 'child_items' annotation works as expected.""" + # Create a trackable part + my_part = Part.objects.create( + name='Test Part', description='Test Part Description', trackable=True + ) + + # Create an initial stock item + parent_item = StockItem.objects.create( + part=my_part, quantity=10, location=StockLocation.objects.first() + ) + + # Serialize this stock item + parent_item.serializeStock( + 5, [1, 2, 3, 4, 5], user=self.user, notes='Some notes' + ) + + parent_item.refresh_from_db() + + # Check that the parent item has 5 child items + self.assertEqual(parent_item.get_descendants(include_self=False).count(), 5) + self.assertEqual(my_part.stock_items.count(), 6) + + # Fetch stock list via API + response = self.get(reverse('api-stock-list'), {'part': my_part.pk}) + + self.assertEqual(len(response.data), 6) + + # Fetch stock detail + response = self.get(reverse('api-stock-detail', kwargs={'pk': parent_item.pk})) + + self.assertEqual(response.data['child_items'], 5) + + for child in parent_item.get_children(): + response = self.get(reverse('api-stock-detail', kwargs={'pk': child.pk})) + + self.assertEqual(response.data['parent'], parent_item.pk) + self.assertEqual(response.data['quantity'], 1) + self.assertEqual(response.data['child_items'], 0) + class CustomStockItemStatusTest(StockAPITestCase): """Tests for custom stock item statuses.""" diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index b0bae1fd87..7c9d059748 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -116,12 +116,14 @@ export enum ApiEndpoints { manufacturer_part_list = 'company/part/manufacturer/', manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/', - // Stock API endpoints - stock_item_list = 'stock/', - stock_tracking_list = 'stock/track/', + // Stock location endpoints stock_location_list = 'stock/location/', stock_location_type_list = 'stock/location-type/', stock_location_tree = 'stock/location/tree/', + + // Stock item API endpoints + stock_item_list = 'stock/', + stock_tracking_list = 'stock/track/', stock_test_result_list = 'stock/test/', stock_transfer = 'stock/transfer/', stock_remove = 'stock/remove/', @@ -131,9 +133,10 @@ export enum ApiEndpoints { stock_merge = 'stock/merge/', stock_assign = 'stock/assign/', stock_status = 'stock/status/', - stock_install = 'stock/:id/install', - build_test_statistics = 'test-statistics/by-build/:id', - part_test_statistics = 'test-statistics/by-part/:id', + stock_install = 'stock/:id/install/', + stock_serialize = 'stock/:id/serialize/', + build_test_statistics = 'test-statistics/by-build/:id/', + part_test_statistics = 'test-statistics/by-part/:id/', // Generator API endpoints generate_batch_code = 'generate/batch-code/', diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 98a30d4d7d..8539e7116c 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -40,6 +40,7 @@ import { useBatchCodeGenerator, useSerialNumberGenerator } from '../hooks/UseGenerator'; +import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder'; import { apiUrl } from '../states/ApiState'; import { useGlobalSettingsState } from '../states/SettingsState'; @@ -214,6 +215,30 @@ export function useCreateStockItem() { }); } +export function useStockItemSerializeFields({ + partId, + trackable +}: { + partId: number; + trackable: boolean; +}) { + const snPlaceholder = useSerialNumberPlaceholder({ + partId: partId, + key: 'stock-item-serialize', + enabled: trackable + }); + + return useMemo(() => { + return { + quantity: {}, + serial_numbers: { + placeholder: snPlaceholder + }, + destination: {} + }; + }, [snPlaceholder]); +} + function StockItemDefaultMove({ stockItem, value diff --git a/src/frontend/src/hooks/UseInstance.tsx b/src/frontend/src/hooks/UseInstance.tsx index 29713bd8e4..32414e58eb 100644 --- a/src/frontend/src/hooks/UseInstance.tsx +++ b/src/frontend/src/hooks/UseInstance.tsx @@ -90,7 +90,7 @@ export function useInstance({ }); const refreshInstance = useCallback(function () { - instanceQuery.refetch(); + return instanceQuery.refetch(); }, []); return { diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 6d18482ac9..512b8b28b0 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -39,12 +39,14 @@ import { StatusRenderer } from '../../components/render/StatusRenderer'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; +import { partCategoryFields } from '../../forms/PartForms'; import { StockOperationProps, useAddStockItem, useCountStockItem, useRemoveStockItem, useStockFields, + useStockItemSerializeFields, useTransferStockItem } from '../../forms/StockForms'; import { InvenTreeIcon } from '../../functions/icons'; @@ -203,6 +205,16 @@ export default function StockDetail() { model: ModelType.stockitem, hidden: !stockitem.belongs_to }, + { + type: 'link', + name: 'parent', + label: t`Parent Item`, + model: ModelType.stockitem, + hidden: !stockitem.parent, + model_formatter: (model: any) => { + return t`Parent stock item`; + } + }, { type: 'link', name: 'consumed_by', @@ -481,8 +493,39 @@ export default function StockDetail() { const removeStockItem = useRemoveStockItem(stockActionProps); const transferStockItem = useTransferStockItem(stockActionProps); - const stockActions = useMemo( - () => [ + const serializeStockFields = useStockItemSerializeFields({ + partId: stockitem.part, + trackable: stockitem.part_detail?.trackable + }); + + const serializeStockItem = useCreateApiFormModal({ + url: ApiEndpoints.stock_serialize, + pk: stockitem.pk, + title: t`Serialize Stock Item`, + fields: serializeStockFields, + initialData: { + quantity: stockitem.quantity, + destination: stockitem.location ?? stockitem.part_detail?.default_location + }, + onFormSuccess: () => { + const partId = stockitem.part; + refreshInstance().catch(() => { + // Part may have been deleted - redirect to the part detail page + navigate(getDetailUrl(ModelType.part, partId)); + }); + }, + successMessage: t`Stock item serialized` + }); + + const stockActions = useMemo(() => { + const serial = stockitem.serial; + const serialized = + serial != null && + serial != undefined && + serial != '' && + stockitem.quantity == 1; + + return [ , ), @@ -513,6 +557,7 @@ export default function StockDetail() { { name: t`Add`, tooltip: t`Add stock`, + hidden: serialized, icon: , onClick: () => { stockitem.pk && addStockItem.open(); @@ -521,11 +566,21 @@ export default function StockDetail() { { name: t`Remove`, tooltip: t`Remove stock`, + hidden: serialized, icon: , onClick: () => { stockitem.pk && removeStockItem.open(); } }, + { + name: t`Serialize`, + tooltip: t`Serialize stock`, + hidden: serialized || stockitem?.part_detail?.trackable != true, + icon: , + onClick: () => { + serializeStockItem.open(); + } + }, { name: t`Transfer`, tooltip: t`Transfer stock`, @@ -555,9 +610,8 @@ export default function StockDetail() { }) ]} /> - ], - [id, stockitem, user] - ); + ]; + }, [id, stockitem, user]); const stockBadges: ReactNode[] = useMemo(() => { let available = (stockitem?.quantity ?? 0) - (stockitem?.allocated ?? 0); @@ -642,6 +696,7 @@ export default function StockDetail() { {addStockItem.modal} {removeStockItem.modal} {transferStockItem.modal} + {serializeStockItem.modal} );