2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36: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 information."""
# InvenTree API version # 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.""" """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 = """
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 v336 -> 2025-04-10 : https://github.com/inventree/InvenTree/pull/9492
- Fixed query and response serialization for units_all and version_text - Fixed query and response serialization for units_all and version_text
- Fixed LicenseView and VersionInformation serialization - Fixed LicenseView and VersionInformation serialization

View File

@ -32,18 +32,67 @@ from .settings import MEDIA_URL, STATIC_URL
logger = structlog.get_logger('inventree') 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 # Default value if we cannot convert to an integer
ref_int = 0 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() reference = str(reference).strip()
# Ignore empty string # Ignore empty string
if len(reference) == 0: if len(reference) == 0:
return 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"? # Look at the start of the string - can it be "integerized"?
result = re.match(r'^(\d+)', reference) 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 # 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" # Note: This will result in large values being "clipped"
if clip is not None: ref_int = do_clip(ref_int, clip, allow_negative)
if ref_int > clip:
ref_int = clip
elif ref_int < -clip:
ref_int = -clip
if not allow_negative and ref_int < 0: if not allow_negative and ref_int < 0:
ref_int = abs(ref_int) ref_int = abs(ref_int)

View File

@ -1227,6 +1227,17 @@ class StockDetail(StockApiMixin, RetrieveUpdateDestroyAPI):
"""API detail endpoint for a single StockItem instance.""" """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: class StockItemTestResultMixin:
"""Mixin class for the StockItemTestResult API endpoints.""" """Mixin class for the StockItemTestResult API endpoints."""
@ -1615,6 +1626,11 @@ stock_api_urls = [
StockItemUninstall.as_view(), StockItemUninstall.as_view(),
name='api-stock-item-uninstall', 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'), path('', StockDetail.as_view(), name='api-stock-detail'),
]), ]),
), ),

View File

@ -692,6 +692,16 @@ class StockItem(
return None 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): def save(self, *args, **kwargs):
"""Save this StockItem to the database. """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 importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField from InvenTree.serializers import (
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeModelSerializer,
)
from users.serializers import UserSerializer from users.serializers import UserSerializer
from .models import ( from .models import (
@ -1808,3 +1812,23 @@ class StockTransferSerializer(StockAdjustmentSerializer):
stock_item.move( stock_item.move(
location, notes, request.user, quantity=quantity, **kwargs 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, FormProvider,
type SubmitErrorHandler, type SubmitErrorHandler,
type SubmitHandler, type SubmitHandler,
type UseFormReturn,
useForm useForm
} from 'react-hook-form'; } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; 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 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 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 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 modelType : Define a model type for this form
* @param follow : Boolean, follow the result of the form (if possible) * @param follow : Boolean, follow the result of the form (if possible)
* @param table : Table to update on success (if provided) * @param table : Table to update on success (if provided)
@ -94,9 +96,10 @@ export interface ApiFormProps {
preFormSuccess?: string; preFormSuccess?: string;
postFormContent?: JSX.Element; postFormContent?: JSX.Element;
successMessage?: string | null; successMessage?: string | null;
onFormSuccess?: (data: any) => void; onFormSuccess?: (data: any, form: UseFormReturn) => void;
onFormError?: (response: any) => void; onFormError?: (response: any, form: UseFormReturn) => void;
processFormData?: (data: any) => any; processFormData?: (data: any, form: UseFormReturn) => any;
checkClose?: (data: any, form: UseFormReturn) => boolean;
table?: TableState; table?: TableState;
modelType?: ModelType; modelType?: ModelType;
follow?: boolean; follow?: boolean;
@ -414,7 +417,7 @@ export function ApiForm({
// Optionally pre-process the data before submitting it // Optionally pre-process the data before submitting it
if (props.processFormData) { if (props.processFormData) {
data = props.processFormData(data); data = props.processFormData(data, form);
} }
const jsonData = { ...data }; const jsonData = { ...data };
@ -474,7 +477,7 @@ export function ApiForm({
if (props.onFormSuccess) { if (props.onFormSuccess) {
// A custom callback hook is provided // A custom callback hook is provided
props.onFormSuccess(response.data); props.onFormSuccess(response.data, form);
} }
if (props.follow && props.modelType && response.data?.pk) { if (props.follow && props.modelType && response.data?.pk) {
@ -507,7 +510,7 @@ export function ApiForm({
default: default:
// Unexpected state on form success // Unexpected state on form success
invalidResponse(response.status); invalidResponse(response.status);
props.onFormError?.(response); props.onFormError?.(response, form);
break; break;
} }
@ -572,18 +575,18 @@ export function ApiForm({
processErrors(error.response.data); processErrors(error.response.data);
setNonFieldErrors(_nonFieldErrors); setNonFieldErrors(_nonFieldErrors);
props.onFormError?.(error); props.onFormError?.(error, form);
break; break;
default: default:
// Unexpected state on form error // Unexpected state on form error
invalidResponse(error.response.status); invalidResponse(error.response.status);
props.onFormError?.(error); props.onFormError?.(error, form);
break; break;
} }
} else { } else {
showTimeoutNotification(); showTimeoutNotification();
props.onFormError?.(error); props.onFormError?.(error, form);
} }
return error; return error;
@ -592,7 +595,7 @@ export function ApiForm({
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>( const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(
(error: any) => { (error: any) => {
props.onFormError?.(error); props.onFormError?.(error, form);
}, },
[props.onFormError] [props.onFormError]
); );

View File

@ -152,6 +152,7 @@ export enum ApiEndpoints {
stock_uninstall = 'stock/:id/uninstall/', stock_uninstall = 'stock/:id/uninstall/',
stock_serialize = 'stock/:id/serialize/', stock_serialize = 'stock/:id/serialize/',
stock_return = 'stock/:id/return/', stock_return = 'stock/:id/return/',
stock_serial_info = 'stock/:id/serial-numbers/',
// Generator API endpoints // Generator API endpoints
generate_batch_code = 'generate/batch-code/', 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 { Suspense, useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { api } from '../App'; import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton'; import { ActionButton } from '../components/buttons/ActionButton';
import RemoveRowButton from '../components/buttons/RemoveRowButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton';
@ -33,8 +34,10 @@ import { StatusRenderer } from '../components/render/StatusRenderer';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType'; import { ModelType } from '../enums/ModelType';
import { InvenTreeIcon } from '../functions/icons'; import { InvenTreeIcon } from '../functions/icons';
import { getDetailUrl } from '../functions/urls';
import { import {
type ApiFormModalProps, type ApiFormModalProps,
useApiFormModal,
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal useDeleteApiFormModal
} from '../hooks/UseForm'; } from '../hooks/UseForm';
@ -1296,3 +1299,56 @@ export function useTestResultFields({
includeTestStation 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, IconQuestionMark,
IconRefresh, IconRefresh,
IconRulerMeasure, IconRulerMeasure,
IconSearch,
IconSettings, IconSettings,
IconShoppingCart, IconShoppingCart,
IconShoppingCartHeart, IconShoppingCartHeart,
@ -254,7 +255,8 @@ const icons = {
success: IconCircleCheck, success: IconCircleCheck,
plugin: IconPlug, plugin: IconPlug,
history: IconHistory, history: IconHistory,
dashboard: IconLayoutDashboard dashboard: IconLayoutDashboard,
search: IconSearch
}; };
export type InvenTreeIconType = keyof typeof icons; export type InvenTreeIconType = keyof typeof icons;

View File

@ -44,12 +44,14 @@ export function useApiFormModal(props: ApiFormModalProps) {
} }
} }
], ],
onFormSuccess: (data) => { onFormSuccess: (data, form) => {
if (props.checkClose?.(data, form) ?? true) {
modalClose.current(); modalClose.current();
props.onFormSuccess?.(data); }
props.onFormSuccess?.(data, form);
}, },
onFormError: (error: any) => { onFormError: (error: any, form) => {
props.onFormError?.(error); props.onFormError?.(error, form);
} }
}), }),
[props] [props]

View File

@ -20,6 +20,7 @@ import {
IconListTree, IconListTree,
IconLock, IconLock,
IconPackages, IconPackages,
IconSearch,
IconShoppingCart, IconShoppingCart,
IconStack2, IconStack2,
IconTestPipe, IconTestPipe,
@ -71,6 +72,7 @@ import { usePartFields } from '../../forms/PartForms';
import { import {
type StockOperationProps, type StockOperationProps,
useCountStockItem, useCountStockItem,
useFindSerialNumberForm,
useTransferStockItem useTransferStockItem
} from '../../forms/StockForms'; } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
@ -879,6 +881,8 @@ export default function PartDetail() {
parts: [part] parts: [part]
}); });
const findBySerialNumber = useFindSerialNumberForm({ partId: part.pk });
const partActions = useMemo(() => { const partActions = useMemo(() => {
return [ return [
<AdminButton model={ModelType.part} id={part.pk} />, <AdminButton model={ModelType.part} id={part.pk} />,
@ -940,6 +944,13 @@ export default function PartDetail() {
onClick: () => { onClick: () => {
orderPartsWizard.openWizard(); 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} {duplicatePart.modal}
{editPart.modal} {editPart.modal}
{deletePart.modal} {deletePart.modal}
{findBySerialNumber.modal}
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
<InstanceDetail <InstanceDetail
status={requestStatus} status={requestStatus}

View File

@ -1,12 +1,26 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Accordion, Alert, Grid, Skeleton, Stack } from '@mantine/core';
import { import {
Accordion,
Alert,
Button,
Grid,
Group,
Skeleton,
Space,
Stack,
Text,
Tooltip
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconBookmark, IconBookmark,
IconBoxPadding, IconBoxPadding,
IconChecklist, IconChecklist,
IconHistory, IconHistory,
IconInfoCircle, IconInfoCircle,
IconPackages, IconPackages,
IconSearch,
IconShoppingCart, IconShoppingCart,
IconSitemap IconSitemap
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@ -14,6 +28,7 @@ import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useMemo, useState } from 'react'; import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { ActionButton } from '../../components/buttons/ActionButton';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions'; import { PrintingActions } from '../../components/buttons/PrintingActions';
import { import {
@ -52,6 +67,7 @@ import {
useAddStockItem, useAddStockItem,
useAssignStockItem, useAssignStockItem,
useCountStockItem, useCountStockItem,
useFindSerialNumberForm,
useRemoveStockItem, useRemoveStockItem,
useStockFields, useStockFields,
useStockItemSerializeFields, 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 detailsPanel = useMemo(() => {
const data = { ...stockitem }; const data = { ...stockitem };
const part = stockitem?.part_detail ?? {}; const part = stockitem?.part_detail ?? {};
@ -150,13 +176,6 @@ export default function StockDetail() {
!stockitem.status_custom_key || !stockitem.status_custom_key ||
stockitem.status_custom_key == stockitem.status stockitem.status_custom_key == stockitem.status
}, },
{
type: 'text',
name: 'tests',
label: t`Completed Tests`,
icon: 'progress',
hidden: !part?.testable
},
{ {
type: 'text', type: 'text',
name: 'updated', name: 'updated',
@ -176,14 +195,69 @@ export default function StockDetail() {
const tr: DetailsField[] = [ const tr: DetailsField[] = [
{ {
type: 'text', type: 'text',
name: 'quantity', name: 'serial',
label: t`Quantity` 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', type: 'text',
name: 'serial', name: 'quantity',
label: t`Serial Number`, label: t`Quantity`,
hidden: !stockitem.serial hidden: !!stockitem.serial && stockitem.quantity == 1
}, },
{ {
type: 'text', type: 'text',
@ -365,7 +439,13 @@ export default function StockDetail() {
<DetailsTable fields={br} item={data} /> <DetailsTable fields={br} item={data} />
</ItemDetailsGrid> </ItemDetailsGrid>
); );
}, [stockitem, instanceQuery.isFetching, enableExpiry]); }, [
stockitem,
serialNumbers,
serialNumbersQuery.isFetching,
instanceQuery.isFetching,
enableExpiry
]);
const showBuildAllocations: boolean = useMemo(() => { const showBuildAllocations: boolean = useMemo(() => {
// Determine if "build allocations" should be shown for this stock item // Determine if "build allocations" should be shown for this stock item
@ -539,6 +619,8 @@ export default function StockDetail() {
showBuildAllocations, showBuildAllocations,
showInstalledItems, showInstalledItems,
stockitem, stockitem,
serialNumbers,
serialNumbersQuery,
id, id,
user user
]); ]);
@ -890,6 +972,8 @@ export default function StockDetail() {
}, [stockitem, instanceQuery, enableExpiry]); }, [stockitem, instanceQuery, enableExpiry]);
return ( return (
<>
{findBySerialNumber.modal}
<InstanceDetail <InstanceDetail
requiredRole={UserRoles.stock} requiredRole={UserRoles.stock}
status={requestStatus} status={requestStatus}
@ -914,7 +998,9 @@ export default function StockDetail() {
editEnabled={user.hasChangePermission(ModelType.stockitem)} editEnabled={user.hasChangePermission(ModelType.stockitem)}
badges={stockBadges} badges={stockBadges}
breadcrumbs={ breadcrumbs={
user.hasViewRole(UserRoles.stock_location) ? breadcrumbs : undefined user.hasViewRole(UserRoles.stock_location)
? breadcrumbs
: undefined
} }
lastCrumb={[ lastCrumb={[
{ {
@ -947,5 +1033,6 @@ export default function StockDetail() {
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
</Stack> </Stack>
</InstanceDetail> </InstanceDetail>
</>
); );
} }

View File

@ -165,6 +165,33 @@ test('Stock - Serial Numbers', async ({ browser }) => {
await page.getByRole('button', { name: 'Cancel' }).click(); 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 }) => { test('Stock - Serialize', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'stock/item/232/details' }); const page = await doCachedLogin(browser, { url: 'stock/item/232/details' });