2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-27 19:16:44 +00:00

[UI] Serial Number Navigation (#9505)

* Add checkClose function to forms

- Allow custom check for whether form should be closed

* Add form to jump to serial number

* Tweak stock detail display

* Remove dead field

(might fix later, but it's hard with the current API)

* Add some icons

* Enhance extract_int functionality

* Add API endpoint for "next" and "previous" serials for a given stock item

* Add serial number navigation on stock item page

* Add playwright tests

* Bump API version

* Fix for serial number clipping

* Another tweak
This commit is contained in:
Oliver 2025-04-15 12:42:25 +10:00 committed by GitHub
parent 8d44a0d330
commit 448d24de21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 383 additions and 95 deletions

View File

@ -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

View File

@ -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)

View File

@ -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'),
]),
),

View File

@ -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.

View File

@ -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'),
)

View File

@ -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<SubmitErrorHandler<FieldValues>>(
(error: any) => {
props.onFormError?.(error);
props.onFormError?.(error, form);
},
[props.onFormError]
);

View File

@ -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/',

View File

@ -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));
}
}
});
}

View File

@ -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;

View File

@ -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]

View File

@ -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 [
<AdminButton model={ModelType.part} id={part.pk} />,
@ -940,6 +944,13 @@ export default function PartDetail() {
onClick: () => {
orderPartsWizard.openWizard();
}
},
{
name: t`Search`,
tooltip: t`Search by serial number`,
hidden: !part.trackable,
icon: <IconSearch />,
onClick: findBySerialNumber.open
}
]}
/>,
@ -976,6 +987,7 @@ export default function PartDetail() {
{duplicatePart.modal}
{editPart.modal}
{deletePart.modal}
{findBySerialNumber.modal}
{orderPartsWizard.wizard}
<InstanceDetail
status={requestStatus}

View File

@ -1,12 +1,26 @@
import { t } from '@lingui/core/macro';
import { Accordion, Alert, Grid, Skeleton, Stack } from '@mantine/core';
import {
Accordion,
Alert,
Button,
Grid,
Group,
Skeleton,
Space,
Stack,
Text,
Tooltip
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconBookmark,
IconBoxPadding,
IconChecklist,
IconHistory,
IconInfoCircle,
IconPackages,
IconSearch,
IconShoppingCart,
IconSitemap
} from '@tabler/icons-react';
@ -14,6 +28,7 @@ import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ActionButton } from '../../components/buttons/ActionButton';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
@ -52,6 +67,7 @@ import {
useAddStockItem,
useAssignStockItem,
useCountStockItem,
useFindSerialNumberForm,
useRemoveStockItem,
useStockFields,
useStockItemSerializeFields,
@ -108,6 +124,16 @@ export default function StockDetail() {
}
});
const { instance: serialNumbers, instanceQuery: serialNumbersQuery } =
useInstance({
endpoint: ApiEndpoints.stock_serial_info,
pk: id
});
const findBySerialNumber = useFindSerialNumberForm({
partId: stockitem.part
});
const detailsPanel = useMemo(() => {
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: () => (
<Group gap='xs' justify='space-apart'>
<Text>{stockitem.serial}</Text>
<Space flex={10} />
<Group gap={2} justify='right'>
{serialNumbers.previous?.pk && (
<Tooltip label={t`Previous serial number`} position='top'>
<Button
p={3}
aria-label='previous-serial-number'
leftSection={<IconArrowLeft />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(
ModelType.stockitem,
serialNumbers.previous.pk
)
);
}}
>
{serialNumbers.previous.serial}
</Button>
</Tooltip>
)}
<ActionButton
icon={<IconSearch size={18} />}
tooltip={t`Find serial number`}
tooltipAlignment='top'
variant='transparent'
onClick={findBySerialNumber.open}
/>
{serialNumbers.next?.pk && (
<Tooltip label={t`Next serial number`} position='top'>
<Button
p={3}
aria-label='next-serial-number'
rightSection={<IconArrowRight />}
variant='transparent'
size='sm'
onClick={() => {
navigate(
getDetailUrl(ModelType.stockitem, serialNumbers.next.pk)
);
}}
>
{serialNumbers.next.serial}
</Button>
</Tooltip>
)}
</Group>
</Group>
)
},
{
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() {
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
);
}, [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 (
<InstanceDetail
requiredRole={UserRoles.stock}
status={requestStatus}
loading={instanceQuery.isFetching}
>
<Stack>
{user.hasViewRole(UserRoles.stock_location) && (
<NavigationTree
title={t`Stock Locations`}
modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree}
opened={treeOpen}
onClose={() => setTreeOpen(false)}
selectedId={stockitem?.location}
/>
)}
<PageDetail
title={t`Stock Item`}
subtitle={stockitem.part_detail?.full_name}
imageUrl={stockitem.part_detail?.thumbnail}
editAction={editStockItem.open}
editEnabled={user.hasChangePermission(ModelType.stockitem)}
badges={stockBadges}
breadcrumbs={
user.hasViewRole(UserRoles.stock_location) ? breadcrumbs : undefined
}
lastCrumb={[
{
name: stockitem.name,
url: `/stock/item/${stockitem.pk}/`
<>
{findBySerialNumber.modal}
<InstanceDetail
requiredRole={UserRoles.stock}
status={requestStatus}
loading={instanceQuery.isFetching}
>
<Stack>
{user.hasViewRole(UserRoles.stock_location) && (
<NavigationTree
title={t`Stock Locations`}
modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree}
opened={treeOpen}
onClose={() => setTreeOpen(false)}
selectedId={stockitem?.location}
/>
)}
<PageDetail
title={t`Stock Item`}
subtitle={stockitem.part_detail?.full_name}
imageUrl={stockitem.part_detail?.thumbnail}
editAction={editStockItem.open}
editEnabled={user.hasChangePermission(ModelType.stockitem)}
badges={stockBadges}
breadcrumbs={
user.hasViewRole(UserRoles.stock_location)
? breadcrumbs
: undefined
}
]}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={stockActions}
/>
<PanelGroup
pageKey='stockitem'
panels={stockPanels}
model={ModelType.stockitem}
id={stockitem.pk}
instance={stockitem}
/>
{editStockItem.modal}
{duplicateStockItem.modal}
{deleteStockItem.modal}
{countStockItem.modal}
{addStockItem.modal}
{removeStockItem.modal}
{transferStockItem.modal}
{serializeStockItem.modal}
{returnStockItem.modal}
{assignToCustomer.modal}
{orderPartsWizard.wizard}
</Stack>
</InstanceDetail>
lastCrumb={[
{
name: stockitem.name,
url: `/stock/item/${stockitem.pk}/`
}
]}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={stockActions}
/>
<PanelGroup
pageKey='stockitem'
panels={stockPanels}
model={ModelType.stockitem}
id={stockitem.pk}
instance={stockitem}
/>
{editStockItem.modal}
{duplicateStockItem.modal}
{deleteStockItem.modal}
{countStockItem.modal}
{addStockItem.modal}
{removeStockItem.modal}
{transferStockItem.modal}
{serializeStockItem.modal}
{returnStockItem.modal}
{assignToCustomer.modal}
{orderPartsWizard.wizard}
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -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' });