diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 6e3ebb81c4..274428ff7c 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 336 +INVENTREE_API_VERSION = 337 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v337 -> 2025-04-15 : https://github.com/inventree/InvenTree/pull/9505 + - Adds API endpoint with extra serial number information for a given StockItem object + v336 -> 2025-04-10 : https://github.com/inventree/InvenTree/pull/9492 - Fixed query and response serialization for units_all and version_text - Fixed LicenseView and VersionInformation serialization diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 0b1a7a7628..ee6ffe642c 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -32,18 +32,67 @@ from .settings import MEDIA_URL, STATIC_URL logger = structlog.get_logger('inventree') +INT_CLIP_MAX = 0x7FFFFFFF -def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False): - """Extract an integer out of reference.""" + +def extract_int( + reference, clip=INT_CLIP_MAX, try_hex=False, allow_negative=False +) -> int: + """Extract an integer out of provided string. + + Arguments: + reference: Input string to extract integer from + clip: Maximum value to return (default = 0x7FFFFFFF) + try_hex: Attempt to parse as hex if integer conversion fails (default = False) + allow_negative: Allow negative values (default = False) + """ # Default value if we cannot convert to an integer ref_int = 0 + def do_clip(value: int, clip: int, allow_negative: bool) -> int: + """Perform clipping on the provided value. + + Arguments: + value: Value to clip + clip: Maximum value to clip to + allow_negative: Allow negative values (default = False) + """ + if clip is None: + return value + + clip = min(clip, INT_CLIP_MAX) + + if value > clip: + return clip + elif value < -clip: + return -clip + + if not allow_negative: + value = abs(value) + + return value + reference = str(reference).strip() # Ignore empty string if len(reference) == 0: return 0 + # Try naive integer conversion first + try: + ref_int = int(reference) + return do_clip(ref_int, clip, allow_negative) + except ValueError: + pass + + # Hex? + if try_hex or reference.startswith('0x'): + try: + ref_int = int(reference, base=16) + return do_clip(ref_int, clip, allow_negative) + except ValueError: + pass + # Look at the start of the string - can it be "integerized"? result = re.match(r'^(\d+)', reference) @@ -66,11 +115,7 @@ def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False): # Ensure that the returned values are within the range that can be stored in an IntegerField # Note: This will result in large values being "clipped" - if clip is not None: - if ref_int > clip: - ref_int = clip - elif ref_int < -clip: - ref_int = -clip + ref_int = do_clip(ref_int, clip, allow_negative) if not allow_negative and ref_int < 0: ref_int = abs(ref_int) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index f4119210ee..1819c49878 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -1227,6 +1227,17 @@ class StockDetail(StockApiMixin, RetrieveUpdateDestroyAPI): """API detail endpoint for a single StockItem instance.""" +class StockItemSerialNumbers(RetrieveAPI): + """View extra serial number information for a given stock item. + + Provides information on the "previous" and "next" stock items, + based on the serial number of the given stock item. + """ + + queryset = StockItem.objects.all() + serializer_class = StockSerializers.StockItemSerialNumbersSerializer + + class StockItemTestResultMixin: """Mixin class for the StockItemTestResult API endpoints.""" @@ -1615,6 +1626,11 @@ stock_api_urls = [ StockItemUninstall.as_view(), name='api-stock-item-uninstall', ), + path( + 'serial-numbers/', + StockItemSerialNumbers.as_view(), + name='api-stock-item-serial-numbers', + ), path('', StockDetail.as_view(), name='api-stock-detail'), ]), ), diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 0328074af9..168a684eaa 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -692,6 +692,16 @@ class StockItem( return None + @property + def get_next_stock_item(self): + """Return the 'next' stock item (based on serial number).""" + return self.get_next_serialized_item() + + @property + def get_previous_stock_item(self): + """Return the 'previous' stock item (based on serial number).""" + return self.get_next_serialized_item(reverse=True) + def save(self, *args, **kwargs): """Save this StockItem to the database. diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index d7f0102e1b..8a82328a54 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -32,7 +32,11 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin from importer.registry import register_importer from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.ready import isGeneratingSchema -from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField +from InvenTree.serializers import ( + InvenTreeCurrencySerializer, + InvenTreeDecimalField, + InvenTreeModelSerializer, +) from users.serializers import UserSerializer from .models import ( @@ -1808,3 +1812,23 @@ class StockTransferSerializer(StockAdjustmentSerializer): stock_item.move( location, notes, request.user, quantity=quantity, **kwargs ) + + +class StockItemSerialNumbersSerializer(InvenTreeModelSerializer): + """Serializer for extra serial number information about a stock item.""" + + class Meta: + """Metaclass options.""" + + model = StockItem + fields = ['next', 'previous'] + + next = StockItemSerializer( + read_only=True, source='get_next_stock_item', label=_('Next Serial Number') + ) + + previous = StockItemSerializer( + read_only=True, + source='get_previous_stock_item', + label=_('Previous Serial Number'), + ) diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 4eb83c3fd5..7841c5b1b6 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -19,6 +19,7 @@ import { FormProvider, type SubmitErrorHandler, type SubmitHandler, + type UseFormReturn, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; @@ -71,6 +72,7 @@ export interface ApiFormAction { * @param onFormSuccess : A callback function to call when the form is submitted successfully. * @param onFormError : A callback function to call when the form is submitted with errors. * @param processFormData : A callback function to process the form data before submission + * @param checkClose: A callback function to check if the form can be closed after submission * @param modelType : Define a model type for this form * @param follow : Boolean, follow the result of the form (if possible) * @param table : Table to update on success (if provided) @@ -94,9 +96,10 @@ export interface ApiFormProps { preFormSuccess?: string; postFormContent?: JSX.Element; successMessage?: string | null; - onFormSuccess?: (data: any) => void; - onFormError?: (response: any) => void; - processFormData?: (data: any) => any; + onFormSuccess?: (data: any, form: UseFormReturn) => void; + onFormError?: (response: any, form: UseFormReturn) => void; + processFormData?: (data: any, form: UseFormReturn) => any; + checkClose?: (data: any, form: UseFormReturn) => boolean; table?: TableState; modelType?: ModelType; follow?: boolean; @@ -414,7 +417,7 @@ export function ApiForm({ // Optionally pre-process the data before submitting it if (props.processFormData) { - data = props.processFormData(data); + data = props.processFormData(data, form); } const jsonData = { ...data }; @@ -474,7 +477,7 @@ export function ApiForm({ if (props.onFormSuccess) { // A custom callback hook is provided - props.onFormSuccess(response.data); + props.onFormSuccess(response.data, form); } if (props.follow && props.modelType && response.data?.pk) { @@ -507,7 +510,7 @@ export function ApiForm({ default: // Unexpected state on form success invalidResponse(response.status); - props.onFormError?.(response); + props.onFormError?.(response, form); break; } @@ -572,18 +575,18 @@ export function ApiForm({ processErrors(error.response.data); setNonFieldErrors(_nonFieldErrors); - props.onFormError?.(error); + props.onFormError?.(error, form); break; default: // Unexpected state on form error invalidResponse(error.response.status); - props.onFormError?.(error); + props.onFormError?.(error, form); break; } } else { showTimeoutNotification(); - props.onFormError?.(error); + props.onFormError?.(error, form); } return error; @@ -592,7 +595,7 @@ export function ApiForm({ const onFormError = useCallback>( (error: any) => { - props.onFormError?.(error); + props.onFormError?.(error, form); }, [props.onFormError] ); diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 5ab71451d5..5c7fe2e542 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -152,6 +152,7 @@ export enum ApiEndpoints { stock_uninstall = 'stock/:id/uninstall/', stock_serialize = 'stock/:id/serialize/', stock_return = 'stock/:id/return/', + stock_serial_info = 'stock/:id/serial-numbers/', // 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 9f38f2d482..18b573961e 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -14,6 +14,7 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { Suspense, useEffect, useMemo, useState } from 'react'; import dayjs from 'dayjs'; +import { useNavigate } from 'react-router-dom'; import { api } from '../App'; import { ActionButton } from '../components/buttons/ActionButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; @@ -33,8 +34,10 @@ import { StatusRenderer } from '../components/render/StatusRenderer'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ModelType } from '../enums/ModelType'; import { InvenTreeIcon } from '../functions/icons'; +import { getDetailUrl } from '../functions/urls'; import { type ApiFormModalProps, + useApiFormModal, useCreateApiFormModal, useDeleteApiFormModal } from '../hooks/UseForm'; @@ -1296,3 +1299,56 @@ export function useTestResultFields({ includeTestStation ]); } + +/** + * Modal form for finding a particular stock item by serial number + */ +export function useFindSerialNumberForm({ + partId +}: { + partId: number; +}) { + const navigate = useNavigate(); + + return useApiFormModal({ + url: apiUrl(ApiEndpoints.stock_item_list), + fetchInitialData: false, + method: 'GET', + title: t`Find Serial Number`, + fields: { + serial: {}, + part_tree: { + value: partId, + hidden: true, + field_type: 'integer' + } + }, + checkClose: (data, form) => { + if (data.length == 0) { + form.setError('serial', { message: t`No matching items` }); + return false; + } + + if (data.length > 1) { + form.setError('serial', { + message: t`Multiple matching items` + }); + return false; + } + + if (data[0].pk) { + return true; + } else { + form.setError('serial', { + message: t`Invalid response from server` + }); + return false; + } + }, + onFormSuccess: (data) => { + if (data.length == 1 && data[0].pk) { + navigate(getDetailUrl(ModelType.stockitem, data[0].pk)); + } + } + }); +} diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index 54cb6041e3..d7e75eece8 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -77,6 +77,7 @@ import { IconQuestionMark, IconRefresh, IconRulerMeasure, + IconSearch, IconSettings, IconShoppingCart, IconShoppingCartHeart, @@ -254,7 +255,8 @@ const icons = { success: IconCircleCheck, plugin: IconPlug, history: IconHistory, - dashboard: IconLayoutDashboard + dashboard: IconLayoutDashboard, + search: IconSearch }; export type InvenTreeIconType = keyof typeof icons; diff --git a/src/frontend/src/hooks/UseForm.tsx b/src/frontend/src/hooks/UseForm.tsx index df5a77ec6d..d651cc53ac 100644 --- a/src/frontend/src/hooks/UseForm.tsx +++ b/src/frontend/src/hooks/UseForm.tsx @@ -44,12 +44,14 @@ export function useApiFormModal(props: ApiFormModalProps) { } } ], - onFormSuccess: (data) => { - modalClose.current(); - props.onFormSuccess?.(data); + onFormSuccess: (data, form) => { + if (props.checkClose?.(data, form) ?? true) { + modalClose.current(); + } + props.onFormSuccess?.(data, form); }, - onFormError: (error: any) => { - props.onFormError?.(error); + onFormError: (error: any, form) => { + props.onFormError?.(error, form); } }), [props] diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 0041b3bbac..9a4cf99740 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -20,6 +20,7 @@ import { IconListTree, IconLock, IconPackages, + IconSearch, IconShoppingCart, IconStack2, IconTestPipe, @@ -71,6 +72,7 @@ import { usePartFields } from '../../forms/PartForms'; import { type StockOperationProps, useCountStockItem, + useFindSerialNumberForm, useTransferStockItem } from '../../forms/StockForms'; import { InvenTreeIcon } from '../../functions/icons'; @@ -879,6 +881,8 @@ export default function PartDetail() { parts: [part] }); + const findBySerialNumber = useFindSerialNumberForm({ partId: part.pk }); + const partActions = useMemo(() => { return [ , @@ -940,6 +944,13 @@ export default function PartDetail() { onClick: () => { orderPartsWizard.openWizard(); } + }, + { + name: t`Search`, + tooltip: t`Search by serial number`, + hidden: !part.trackable, + icon: , + onClick: findBySerialNumber.open } ]} />, @@ -976,6 +987,7 @@ export default function PartDetail() { {duplicatePart.modal} {editPart.modal} {deletePart.modal} + {findBySerialNumber.modal} {orderPartsWizard.wizard} { const data = { ...stockitem }; const part = stockitem?.part_detail ?? {}; @@ -150,13 +176,6 @@ export default function StockDetail() { !stockitem.status_custom_key || stockitem.status_custom_key == stockitem.status }, - { - type: 'text', - name: 'tests', - label: t`Completed Tests`, - icon: 'progress', - hidden: !part?.testable - }, { type: 'text', name: 'updated', @@ -176,14 +195,69 @@ export default function StockDetail() { const tr: DetailsField[] = [ { type: 'text', - name: 'quantity', - label: t`Quantity` + name: 'serial', + label: t`Serial Number`, + hidden: !stockitem.serial, + value_formatter: () => ( + + {stockitem.serial} + + + {serialNumbers.previous?.pk && ( + + + + )} + } + tooltip={t`Find serial number`} + tooltipAlignment='top' + variant='transparent' + onClick={findBySerialNumber.open} + /> + {serialNumbers.next?.pk && ( + + + + )} + + + ) }, { type: 'text', - name: 'serial', - label: t`Serial Number`, - hidden: !stockitem.serial + name: 'quantity', + label: t`Quantity`, + hidden: !!stockitem.serial && stockitem.quantity == 1 }, { type: 'text', @@ -365,7 +439,13 @@ export default function StockDetail() { ); - }, [stockitem, instanceQuery.isFetching, enableExpiry]); + }, [ + stockitem, + serialNumbers, + serialNumbersQuery.isFetching, + instanceQuery.isFetching, + enableExpiry + ]); const showBuildAllocations: boolean = useMemo(() => { // Determine if "build allocations" should be shown for this stock item @@ -539,6 +619,8 @@ export default function StockDetail() { showBuildAllocations, showInstalledItems, stockitem, + serialNumbers, + serialNumbersQuery, id, user ]); @@ -890,62 +972,67 @@ export default function StockDetail() { }, [stockitem, instanceQuery, enableExpiry]); return ( - - - {user.hasViewRole(UserRoles.stock_location) && ( - setTreeOpen(false)} - selectedId={stockitem?.location} - /> - )} - + {findBySerialNumber.modal} + + + {user.hasViewRole(UserRoles.stock_location) && ( + setTreeOpen(false)} + selectedId={stockitem?.location} + /> + )} + { - setTreeOpen(true); - }} - actions={stockActions} - /> - - {editStockItem.modal} - {duplicateStockItem.modal} - {deleteStockItem.modal} - {countStockItem.modal} - {addStockItem.modal} - {removeStockItem.modal} - {transferStockItem.modal} - {serializeStockItem.modal} - {returnStockItem.modal} - {assignToCustomer.modal} - {orderPartsWizard.wizard} - - + lastCrumb={[ + { + name: stockitem.name, + url: `/stock/item/${stockitem.pk}/` + } + ]} + breadcrumbAction={() => { + setTreeOpen(true); + }} + actions={stockActions} + /> + + {editStockItem.modal} + {duplicateStockItem.modal} + {deleteStockItem.modal} + {countStockItem.modal} + {addStockItem.modal} + {removeStockItem.modal} + {transferStockItem.modal} + {serializeStockItem.modal} + {returnStockItem.modal} + {assignToCustomer.modal} + {orderPartsWizard.wizard} + + + ); } diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index 2fce81e152..ea8bfab5a5 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -165,6 +165,33 @@ test('Stock - Serial Numbers', async ({ browser }) => { await page.getByRole('button', { name: 'Cancel' }).click(); }); +// Test navigation by serial number +test('Stock - Serial Navigation', async ({ browser }) => { + const page = await doCachedLogin(browser, { url: 'part/79/details' }); + + await page.getByLabel('action-menu-stock-actions').click(); + await page.getByLabel('action-menu-stock-actions-search').click(); + await page.getByLabel('text-field-serial').fill('359'); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Start at serial 359 + await page.getByText('359', { exact: true }).first().waitFor(); + await page.getByLabel('next-serial-number').waitFor(); + await page.getByLabel('previous-serial-number').click(); + + // Navigate to serial 358 + await page.getByText('358', { exact: true }).first().waitFor(); + + await page.getByLabel('action-button-find-serial').click(); + await page.getByLabel('text-field-serial').fill('200'); + await page.getByRole('button', { name: 'Submit' }).click(); + + await page.getByText('Serial Number: 200').waitFor(); + await page.getByText('200', { exact: true }).first().waitFor(); + await page.getByText('199', { exact: true }).first().waitFor(); + await page.getByText('201', { exact: true }).first().waitFor(); +}); + test('Stock - Serialize', async ({ browser }) => { const page = await doCachedLogin(browser, { url: 'stock/item/232/details' });