2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

[Refactor] Serial generation (#8246)

* Refactor "update_serial_number" method

* Refactor serial number validation

- Query is much more efficient now
- Does not have to check each serial number individually
- Makes use of existing Part class method

* Refactor creation of multiple stock items

* Fix for singular item creation

* Refactor serializeStock method:

- Push "rebuild tree" to background worker
- Use bulk_create actions

* Refactor createion of serialized build outputs

* Prevent 1+N DB hits

* Cleanup

* Cleanup

* Reinstate serial number checks

* Add limit for serial number extraction

* Fix cache config

* Revert cache settings

* Fix for unit tests

* Playwright tests

* Bug fix

* Force False cookie mode in testing

* Revert aria-label for PanelGroup items

- No longer works as expected with playwright locators

* Fix playwright vtest

* Further updates

* Playwright test adjustments

* Remove duplicate locator
This commit is contained in:
Oliver 2024-10-12 10:08:57 +11:00 committed by GitHub
parent f77c8a5b5b
commit 7443d21854
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 527 additions and 307 deletions

View File

@ -487,6 +487,11 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
except ValueError:
raise ValidationError([_('Invalid quantity provided')])
if expected_quantity > 1000:
raise ValidationError({
'quantity': [_('Cannot serialize more than 1000 items at once')]
})
input_string = str(input_string).strip() if input_string else ''
if len(input_string) == 0:

View File

@ -1101,7 +1101,7 @@ COOKIE_MODE = (
# Valid modes (as per the django settings documentation)
valid_cookie_modes = ['lax', 'strict', 'none']
if not DEBUG and COOKIE_MODE in valid_cookie_modes:
if not DEBUG and not TESTING and COOKIE_MODE in valid_cookie_modes:
# Set the cookie mode (in production mode only)
COOKIE_MODE = COOKIE_MODE.capitalize()
else:

View File

@ -263,7 +263,9 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
MAX_QUERY_TIME = 7.5
@contextmanager
def assertNumQueriesLessThan(self, value, using='default', verbose=False, url=None):
def assertNumQueriesLessThan(
self, value, using='default', verbose=False, url=None, log_to_file=False
):
"""Context manager to check that the number of queries is less than a certain value.
Example:
@ -281,6 +283,12 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
) # pragma: no cover
# Useful for debugging, disabled by default
if log_to_file:
with open('queries.txt', 'w', encoding='utf-8') as f:
for q in context.captured_queries:
f.write(str(q['sql']) + '\n')
if verbose and n >= value:
msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover
else:

View File

@ -834,6 +834,16 @@ class Build(
location: Override location
auto_allocate: Automatically allocate stock with matching serial numbers
"""
trackable_parts = self.part.get_trackable_parts()
# Create (and cache) a map of valid parts for allocation
valid_parts = {}
for bom_item in trackable_parts:
parts = bom_item.get_valid_parts_for_allocation()
valid_parts[bom_item.pk] = list([part.pk for part in parts])
user = kwargs.get('user', None)
batch = kwargs.get('batch', self.batch)
location = kwargs.get('location', None)
@ -843,81 +853,51 @@ class Build(
if location is None:
location = self.destination or self.part.get_default_location()
"""
Determine if we can create a single output (with quantity > 0),
or multiple outputs (with quantity = 1)
"""
def _add_tracking_entry(output, user):
"""Helper function to add a tracking entry to the newly created output"""
deltas = {
'quantity': float(output.quantity),
'buildorder': self.pk,
}
if output.batch:
deltas['batch'] = output.batch
if output.serial:
deltas['serial'] = output.serial
if output.location:
deltas['location'] = output.location.pk
output.add_tracking_entry(StockHistoryCode.BUILD_OUTPUT_CREATED, user, deltas)
multiple = False
# Serial numbers are provided? We need to split!
if serials:
multiple = True
# BOM has trackable parts, so we must split!
if self.part.has_trackable_parts:
multiple = True
if multiple:
# We are generating multiple serialized outputs
if serials or self.part.has_trackable_parts:
"""Create multiple build outputs with a single quantity of 1."""
# Quantity *must* be an integer at this point!
quantity = int(quantity)
# Create tracking entries for each item
tracking = []
allocations = []
for ii in range(quantity):
if serials:
serial = serials[ii]
else:
serial = None
output = stock.models.StockItem.objects.create(
quantity=1,
location=location,
outputs = stock.models.StockItem._create_serial_numbers(
serials,
part=self.part,
build=self,
batch=batch,
serial=serial,
is_building=True,
is_building=True
)
_add_tracking_entry(output, user)
for output in outputs:
# Generate a new historical tracking entry
if entry := output.add_tracking_entry(
StockHistoryCode.BUILD_OUTPUT_CREATED,
user,
deltas={
'quantity': 1,
'buildorder': self.pk,
'batch': output.batch,
'serial': output.serial,
'location': location.pk if location else None
},
commit=False
):
tracking.append(entry)
if auto_allocate and serial is not None:
# Auto-allocate stock based on serial number
if auto_allocate:
allocations = []
# Get a list of BomItem objects which point to "trackable" parts
for bom_item in self.part.get_trackable_parts():
parts = bom_item.get_valid_parts_for_allocation()
for bom_item in trackable_parts:
valid_part_ids = valid_parts.get(bom_item.pk, [])
items = stock.models.StockItem.objects.filter(
part__in=parts,
serial=str(serial),
part__pk__in=valid_part_ids,
serial=output.serial,
quantity=1,
).filter(stock.models.StockItem.IN_STOCK_FILTER)
"""
Test if there is a matching serial number!
"""
if items.exists() and items.count() == 1:
stock_item = items[0]
@ -929,15 +909,23 @@ class Build(
)
# Allocate the stock items against the BuildLine
BuildItem.objects.create(
allocations.append(
BuildItem(
build_line=build_line,
stock_item=stock_item,
quantity=1,
install_into=output,
)
)
except BuildLine.DoesNotExist:
pass
# Bulk create tracking entries
stock.models.StockItemTracking.objects.bulk_create(tracking)
# Generate stock allocations
BuildItem.objects.bulk_create(allocations)
else:
"""Create a single build output of the given quantity."""
@ -950,7 +938,16 @@ class Build(
is_building=True
)
_add_tracking_entry(output, user)
output.add_tracking_entry(
StockHistoryCode.BUILD_OUTPUT_CREATED,
user,
deltas={
'quantity': quantity,
'buildorder': self.pk,
'batch': batch,
'location': location.pk if location else None
}
)
if self.status == BuildStatus.PENDING:
self.status = BuildStatus.PRODUCTION.value

View File

@ -404,11 +404,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
})
# Check for conflicting serial numbesr
existing = []
for serial in self.serials:
if not part.validate_serial_number(serial):
existing.append(serial)
existing = part.find_conflicting_serial_numbers(self.serials)
if len(existing) > 0:

View File

@ -833,12 +833,38 @@ class Part(
# This serial number is perfectly valid
return True
def find_conflicting_serial_numbers(self, serials: list):
def find_conflicting_serial_numbers(self, serials: list) -> list:
"""For a provided list of serials, return a list of those which are conflicting."""
from part.models import Part
from stock.models import StockItem
conflicts = []
# First, check for raw conflicts based on efficient database queries
if get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
# Serial number must be unique across *all* parts
parts = Part.objects.all()
else:
# Serial number must only be unique across this part "tree"
parts = Part.objects.filter(tree_id=self.tree_id)
items = StockItem.objects.filter(part__in=parts, serial__in=serials)
items = items.order_by('serial_int', 'serial')
for item in items:
conflicts.append(item.serial)
for serial in serials:
if not self.validate_serial_number(serial, part=self):
if serial in conflicts:
# Already found a conflict, no need to check further
continue
try:
self.validate_serial_number(
serial, raise_error=True, check_duplicates=False
)
except ValidationError:
# Serial number is invalid (as determined by plugin)
conflicts.append(serial)
return conflicts

View File

@ -922,12 +922,12 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
data.update(self.clean_data(request.data))
quantity = data.get('quantity', None)
location = data.get('location', None)
if quantity is None:
raise ValidationError({'quantity': _('Quantity is required')})
try:
Part.objects.prefetch_related(None)
part = Part.objects.get(pk=data.get('part', None))
except (ValueError, Part.DoesNotExist):
raise ValidationError({'part': _('Valid part must be supplied')})
@ -951,15 +951,13 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
serials = None
# Check if a set of serial numbers was provided
serial_numbers = data.get('serial_numbers', '')
serial_numbers = data.pop('serial_numbers', '')
# Check if the supplier_part has a package size defined, which is not 1
if 'supplier_part' in data and data['supplier_part'] is not None:
if supplier_part_id := data.get('supplier_part', None):
try:
supplier_part = SupplierPart.objects.get(
pk=data.get('supplier_part', None)
)
except (ValueError, SupplierPart.DoesNotExist):
supplier_part = SupplierPart.objects.get(pk=supplier_part_id)
except Exception:
raise ValidationError({
'supplier_part': _('The given supplier part does not exist')
})
@ -988,8 +986,7 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
# Now remove the flag from data, so that it doesn't interfere with saving
# Do this regardless of results above
if 'use_pack_size' in data:
data.pop('use_pack_size')
data.pop('use_pack_size', None)
# Assign serial numbers for a trackable part
if serial_numbers:
@ -1011,22 +1008,20 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
invalid = []
errors = []
for serial in serials:
try:
part.validate_serial_number(serial, raise_error=True)
invalid = part.find_conflicting_serial_numbers(serials)
except DjangoValidationError as exc:
# Catch raised error to extract specific error information
invalid.append(serial)
if exc.message not in errors:
errors.append(exc.message)
if len(errors) > 0:
if len(invalid) > 0:
msg = _('The following serial numbers already exist or are invalid')
msg += ' : '
msg += ','.join([str(e) for e in invalid])
raise ValidationError({'serial_numbers': [*errors, msg]})
errors.append(msg)
if len(errors) > 0:
raise ValidationError({'serial_numbers': errors})
except DjangoValidationError as e:
raise ValidationError({
@ -1043,27 +1038,36 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
serializer.is_valid(raise_exception=True)
with transaction.atomic():
# Create an initial StockItem object
item = serializer.save()
if serials:
# Assign the first serial number to the "master" item
item.serial = serials[0]
# Create multiple serialized StockItem objects
items = StockItem._create_serial_numbers(
serials, **serializer.validated_data
)
# Save the item (with user information)
item.save(user=user)
# Next, bulk-create stock tracking entries for the newly created items
tracking = []
if serials:
for serial in serials[1:]:
# Create a duplicate stock item with the next serial number
item.pk = None
item.serial = serial
for item in items:
if entry := item.add_tracking_entry(
StockHistoryCode.CREATED,
user,
deltas={'status': item.status},
location=location,
quantity=float(item.quantity),
commit=False,
):
tracking.append(entry)
item.save(user=user)
StockItemTracking.objects.bulk_create(tracking)
response_data = {'quantity': quantity, 'serial_numbers': serials}
else:
# Create a single StockItem object
# Note: This automatically creates a tracking entry
item = serializer.save()
item.save(user=user)
response_data = serializer.data
return Response(

View File

@ -12,7 +12,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import Q, Sum
from django.db.models import Q, QuerySet, Sum
from django.db.models.functions import Coalesce
from django.db.models.signals import post_delete, post_save, pre_delete
from django.db.utils import IntegrityError, OperationalError
@ -33,6 +33,7 @@ import InvenTree.ready
import InvenTree.tasks
import report.mixins
import report.models
import stock.tasks
from build import models as BuildModels
from common.icons import validate_icon
from common.settings import get_global_setting
@ -459,6 +460,97 @@ class StockItem(
& Q(expiry_date__lt=InvenTree.helpers.current_date())
)
@classmethod
def _create_serial_numbers(cls, serials: list, **kwargs) -> QuerySet:
"""Create multiple stock items with the provided serial numbers.
Arguments:
serials: List of serial numbers to create
**kwargs: Additional keyword arguments to pass to the StockItem creation function
Returns:
QuerySet: The created StockItem objects
raises:
ValidationError: If any of the provided serial numbers are invalid
This method uses bulk_create to create multiple StockItem objects in a single query,
which is much more efficient than creating them one-by-one.
However, it does not perform any validation checks on the provided serial numbers,
and also does not generate any "stock tracking entries".
Note: This is an 'internal' function and should not be used by external code / plugins.
"""
# Ensure the primary-key field is not provided
kwargs.pop('id', None)
kwargs.pop('pk', None)
part = kwargs.get('part')
if not part:
raise ValidationError({'part': _('Part must be specified')})
# Create a list of StockItem objects
items = []
# Provide some default field values
data = {**kwargs}
# Remove some extraneous keys which cause issues
for key in ['parent_id', 'part_id', 'build_id']:
data.pop(key, None)
data['parent'] = kwargs.pop('parent', None)
data['tree_id'] = kwargs.pop('tree_id', 0)
data['level'] = kwargs.pop('level', 0)
data['rght'] = kwargs.pop('rght', 0)
data['lft'] = kwargs.pop('lft', 0)
# Force single quantity for each item
data['quantity'] = 1
for serial in serials:
data['serial'] = serial
data['serial_int'] = StockItem.convert_serial_to_int(serial)
items.append(StockItem(**data))
# Create the StockItem objects in bulk
StockItem.objects.bulk_create(items)
# Return the newly created StockItem objects
return StockItem.objects.filter(part=part, serial__in=serials)
@staticmethod
def convert_serial_to_int(serial: str) -> int:
"""Convert the provided serial number to an integer value.
This function hooks into the plugin system to allow for custom serial number conversion.
"""
from plugin.registry import registry
# First, let any plugins convert this serial number to an integer value
# If a non-null value is returned (by any plugin) we will use that
for plugin in registry.with_mixin('validation'):
serial_int = plugin.convert_serial_to_int(serial)
# Save the first returned result
if serial_int is not None:
# Ensure that it is clipped within a range allowed in the database schema
clip = 0x7FFFFFFF
serial_int = abs(serial_int)
serial_int = min(serial_int, clip)
# Return the first non-null value
return serial_int
# None of the plugins provided a valid integer value
if serial not in [None, '']:
return InvenTree.helpers.extract_int(serial)
else:
return None
def update_serial_number(self):
"""Update the 'serial_int' field, to be an integer representation of the serial number.
@ -466,35 +558,15 @@ class StockItem(
"""
serial = str(getattr(self, 'serial', '')).strip()
from plugin.registry import registry
serial_int = self.convert_serial_to_int(serial)
# First, let any plugins convert this serial number to an integer value
# If a non-null value is returned (by any plugin) we will use that
try:
serial_int = int(serial_int)
serial_int = None
for plugin in registry.with_mixin('validation'):
serial_int = plugin.convert_serial_to_int(serial)
if serial_int is not None:
# Save the first returned result
# Ensure that it is clipped within a range allowed in the database schema
clip = 0x7FFFFFFF
serial_int = abs(serial_int)
serial_int = min(serial_int, clip)
self.serial_int = serial_int
return
# If we get to this point, none of the available plugins provided an integer value
# Default value if we cannot convert to an integer
if serial_int <= 0:
serial_int = 0
except (ValueError, TypeError):
serial_int = 0
if serial not in [None, '']:
serial_int = InvenTree.helpers.extract_int(serial)
self.serial_int = serial_int
@ -1452,6 +1524,7 @@ class StockItem(
user: User,
deltas: dict | None = None,
notes: str = '',
commit: bool = True,
**kwargs,
):
"""Add a history tracking entry for this StockItem.
@ -1461,6 +1534,9 @@ class StockItem(
user (User): The user performing this action
deltas (dict, optional): A map of the changes made to the model. Defaults to None.
notes (str, optional): URL associated with this tracking entry. Defaults to ''.
Returns:
StockItemTracking: The created tracking entry
"""
if deltas is None:
deltas = {}
@ -1471,7 +1547,7 @@ class StockItem(
and len(deltas) == 0
and not notes
):
return
return None
# Has a location been specified?
location = kwargs.get('location')
@ -1485,7 +1561,7 @@ class StockItem(
if quantity:
deltas['quantity'] = float(quantity)
entry = StockItemTracking.objects.create(
entry = StockItemTracking(
item=self,
tracking_type=entry_type.value,
user=user,
@ -1494,8 +1570,11 @@ class StockItem(
deltas=deltas,
)
if commit:
entry.save()
return entry
@transaction.atomic
def serializeStock(self, quantity, serials, user, notes='', location=None):
"""Split this stock item into unique serial numbers.
@ -1536,7 +1615,7 @@ class StockItem(
if type(serials) not in [list, tuple]:
raise ValidationError({
'serial_numbers': _('Serial numbers must be a list of integers')
'serial_numbers': _('Serial numbers must be provided as a list')
})
if quantity != len(serials):
@ -1552,45 +1631,54 @@ class StockItem(
msg = _('Serial numbers already exist') + f': {exists}'
raise ValidationError({'serial_numbers': msg})
# Create a new stock item for each unique serial number
for serial in serials:
# Create a copy of this StockItem
new_item = StockItem.objects.get(pk=self.pk)
new_item.quantity = 1
new_item.serial = serial
new_item.pk = None
new_item.parent = self
# Serialize this StockItem
data = dict(StockItem.objects.filter(pk=self.pk).values()[0])
if location:
new_item.location = location
data['location'] = location
# The item already has a transaction history, don't create a new note
new_item.save(user=user, notes=notes)
data['part'] = self.part
data['parent'] = self
data['tree_id'] = self.tree_id
# Copy entire transaction history
new_item.copyHistoryFrom(self)
# Generate a new serial number for each item
items = StockItem._create_serial_numbers(serials, **data)
# Copy test result history
new_item.copyTestResultsFrom(self)
# Create a new tracking entry for each item
history_items = []
# Create a new stock tracking item
new_item.add_tracking_entry(
for item in items:
if entry := item.add_tracking_entry(
StockHistoryCode.ASSIGNED_SERIAL,
user,
notes=notes,
deltas={'serial': serial},
deltas={'serial': item.serial},
location=location,
)
commit=False,
):
history_items.append(entry)
StockItemTracking.objects.bulk_create(history_items)
# Duplicate test results
test_results = []
for test_result in self.test_results.all():
for item in items:
test_result.pk = None
test_result.stock_item = item
test_results.append(test_result)
StockItemTestResult.objects.bulk_create(test_results)
# 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()
InvenTree.tasks.offload_task(
stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id
)
@transaction.atomic
def copyHistoryFrom(self, other):
@ -1822,12 +1910,10 @@ class StockItem(
self.save()
# Rebuild stock trees as required
try:
for tree_id in tree_ids:
StockItem.objects.partial_rebuild(tree_id=tree_id)
except Exception:
logger.warning('Rebuilding entire StockItem tree during merge_stock_items')
StockItem.objects.rebuild()
InvenTree.tasks.offload_task(
stock.tasks.rebuild_stock_item_tree, tree_id=tree_id
)
@transaction.atomic
def splitStock(self, quantity, location=None, user=None, **kwargs):
@ -1922,11 +2008,9 @@ class StockItem(
)
# Rebuild the tree for this parent item
try:
StockItem.objects.partial_rebuild(tree_id=self.tree_id)
except Exception:
logger.warning('Rebuilding entire StockItem tree')
StockItem.objects.rebuild()
InvenTree.tasks.offload_task(
stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id
)
# Attempt to reload the new item from the database
try:

View File

@ -0,0 +1,24 @@
"""Background tasks for the stock app."""
import logging
logger = logging.getLogger('inventree')
def rebuild_stock_item_tree(tree_id=None):
"""Rebuild the stock tree structure.
The StockItem tree uses the MPTT library to manage the tree structure.
"""
from stock.models import StockItem
if tree_id:
try:
StockItem.objects.partial_rebuild(tree_id)
except Exception:
logger.warning('Failed to rebuild StockItem tree')
# If the partial rebuild fails, rebuild the entire tree
StockItem.objects.rebuild()
else:
# No tree_id provided, so rebuild the entire tree
StockItem.objects.rebuild()

View File

@ -2406,7 +2406,7 @@ class StockStatisticsTest(StockAPITestCase):
fixtures = [*StockAPITestCase.fixtures, 'build']
def test_test_statics(self):
def test_test_statistics(self):
"""Test the test statistics API endpoints."""
part = Part.objects.first()
response = self.get(

View File

@ -13,6 +13,7 @@ export function SpotlightButton() {
onClick={() => firstSpotlight.open()}
title={t`Open spotlight`}
variant="transparent"
aria-label="open-spotlight"
>
<IconCommand />
</ActionIcon>

View File

@ -103,7 +103,11 @@ export function Header() {
<NavTabs />
</Group>
<Group>
<ActionIcon onClick={openSearchDrawer} variant="transparent">
<ActionIcon
onClick={openSearchDrawer}
variant="transparent"
aria-label="open-search"
>
<IconSearch />
</ActionIcon>
<SpotlightButton />
@ -119,6 +123,7 @@ export function Header() {
<ActionIcon
onClick={openNotificationDrawer}
variant="transparent"
aria-label="open-notifications"
>
<IconBell />
</ActionIcon>

View File

@ -68,7 +68,13 @@ function QueryResultGroup({
const model = getModelInfo(query.model);
return (
<Paper shadow="sm" radius="xs" p="md" key={`paper-${query.model}`}>
<Paper
shadow="sm"
radius="xs"
p="md"
key={`paper-${query.model}`}
aria-label={`search-group-${query.model}`}
>
<Stack key={`stack-${query.model}`}>
<Group justify="space-between" wrap="nowrap">
<Group justify="left" gap={5} wrap="nowrap">
@ -84,13 +90,14 @@ function QueryResultGroup({
color="red"
variant="transparent"
radius="xs"
aria-label={`remove-search-group-${query.model}`}
onClick={() => onRemove(query.model)}
>
<IconX />
</ActionIcon>
</Group>
<Divider />
<Stack>
<Stack aria-label={`search-group-results-${query.model}`}>
{query.results.results.map((result: any) => (
<Anchor
onClick={(event: any) =>
@ -367,6 +374,7 @@ export function SearchDrawer({
title={
<Group justify="space-between" gap={1} wrap="nowrap">
<TextInput
aria-label="global-search-input"
placeholder={t`Enter search text`}
radius="xs"
value={value}

View File

@ -127,9 +127,14 @@ function BasePanelGroup({
return (
<Boundary label={`PanelGroup-${pageKey}`}>
<Paper p="sm" radius="xs" shadow="xs">
<Tabs value={currentPanel} orientation="vertical" keepMounted={false}>
<Tabs.List justify="left">
<Paper p="sm" radius="xs" shadow="xs" aria-label={`${pageKey}`}>
<Tabs
value={currentPanel}
orientation="vertical"
keepMounted={false}
aria-label={`panel-group-${pageKey}`}
>
<Tabs.List justify="left" aria-label={`panel-tabs-${pageKey}`}>
{allPanels.map(
(panel) =>
!panel.hidden && (

View File

@ -50,8 +50,10 @@ import { useGlobalSettingsState } from '../states/SettingsState';
export function useStockFields({
item_detail,
part_detail,
partId,
create = false
}: {
partId?: number;
item_detail?: any;
part_detail?: any;
create: boolean;
@ -81,7 +83,7 @@ export function useStockFields({
return useMemo(() => {
const fields: ApiFormFieldSet = {
part: {
value: part,
value: partId,
disabled: !create,
filters: {
active: create ? true : undefined
@ -201,7 +203,8 @@ export function useStockFields({
batchCode,
serialNumbers,
trackable,
create
create,
partId
]);
}

View File

@ -401,7 +401,7 @@ export function StockItemTable({
};
}, [table]);
const stockItemFields = useStockFields({ create: true });
const stockItemFields = useStockFields({ create: true, partId: params.part });
const newStockItem = useCreateApiFormModal({
url: ApiEndpoints.stock_item_list,

View File

@ -5,7 +5,7 @@ test('Modals as admin', async ({ page }) => {
await doQuickLogin(page, 'admin', 'inventree');
// use server info
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'Server Information About this Inventree instance'
@ -17,7 +17,7 @@ test('Modals as admin', async ({ page }) => {
await page.waitForURL('**/platform/home');
// use license info
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'License Information Licenses for dependencies of the service'
@ -44,7 +44,7 @@ test('Modals as admin', async ({ page }) => {
.click();
// use about
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
.click();

View File

@ -178,3 +178,53 @@ test('Purchase Orders - Barcodes', async ({ page }) => {
await page.waitForTimeout(500);
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('Purchase Orders - General', async ({ page }) => {
await doQuickLogin(page);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('cell', { name: 'PO0012' }).click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByRole('tab', { name: 'Received Stock' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByText('Arrow', { exact: true }).click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Supplied Parts' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByText('AVX Corporation').click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('cell', { name: 'West Branch' }).click();
await page.locator('.mantine-ScrollArea-root').click();
await page
.getByRole('row', { name: 'West Branch Yes Surf Avenue 9' })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('text-field-title').waitFor();
await page.getByLabel('text-field-line2').waitFor();
// Read the current value of the cell, to ensure we always *change* it!
const value = await page.getByLabel('text-field-line2').inputValue();
await page
.getByLabel('text-field-line2')
.fill(value == 'old' ? 'new' : 'old');
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Details' }).waitFor();
});

View File

@ -0,0 +1,104 @@
import { test } from '../baseFixtures.js';
import { baseUrl } from '../defaults.js';
import { doQuickLogin } from '../login.js';
test('Stock', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/stock/location/index/`);
await page.waitForURL('**/platform/stock/location/**');
await page.getByRole('tab', { name: 'Location Details' }).click();
await page.waitForURL('**/platform/stock/location/index/details');
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByText('1551ABK').first().click();
await page.getByRole('tab', { name: 'Stock', exact: true }).click();
await page.waitForURL('**/platform/stock/**');
await page.getByRole('tab', { name: 'Stock Locations' }).click();
await page.getByRole('cell', { name: 'Electronics Lab' }).first().click();
await page.getByRole('tab', { name: 'Default Parts' }).click();
await page.getByRole('tab', { name: 'Stock Locations' }).click();
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByRole('tab', { name: 'Location Details' }).click();
await page.goto(`${baseUrl}/stock/item/1194/details`);
await page.getByText('D.123 | Doohickey').waitFor();
await page.getByText('Batch Code: BX-123-2024-2-7').waitFor();
await page.getByRole('tab', { name: 'Stock Tracking' }).click();
await page.getByRole('tab', { name: 'Test Data' }).click();
await page.getByText('395c6d5586e5fb656901d047be27e1f7').waitFor();
await page.getByRole('tab', { name: 'Installed Items' }).click();
});
test('Stock - Location Tree', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/stock/location/index/`);
await page.waitForURL('**/platform/stock/location/**');
await page.getByRole('tab', { name: 'Location Details' }).click();
await page.getByLabel('nav-breadcrumb-action').click();
await page.getByLabel('nav-tree-toggle-1}').click();
await page.getByLabel('nav-tree-item-2').click();
await page.getByLabel('breadcrumb-2-storage-room-a').waitFor();
await page.getByLabel('breadcrumb-1-factory').click();
await page.getByRole('cell', { name: 'Factory' }).first().waitFor();
});
test('Stock - Serial Numbers', async ({ page }) => {
await doQuickLogin(page);
// Use the "global search" functionality to find a part we are interested in
// This is to exercise the search functionality and ensure it is working as expected
await page.getByLabel('open-search').click();
await page.getByLabel('global-search-input').clear();
await page.getByLabel('global-search-input').fill('widget green');
// Remove the "stock item" results group
await page.getByLabel('remove-search-group-stockitem').click();
await page
.getByText(/widget\.green/)
.first()
.click();
await page
.getByLabel('panel-tabs-part')
.getByRole('tab', { name: 'Stock', exact: true })
.click();
await page.getByLabel('action-button-add-stock-item').click();
// Initially fill with invalid serial/quantity combinations
await page.getByLabel('text-field-serial_numbers').fill('200-250');
await page.getByLabel('number-field-quantity').fill('10');
await page.getByRole('button', { name: 'Submit' }).click();
// Expected error messages
await page.getByText('Errors exist for one or more form fields').waitFor();
await page
.getByText(/exceeds allowed quantity/)
.first()
.waitFor();
// Now, with correct quantity
await page.getByLabel('number-field-quantity').fill('51');
await page.getByRole('button', { name: 'Submit' }).click();
await page
.getByText(
/The following serial numbers already exist or are invalid : 200,201,202,203,204/
)
.first()
.waitFor();
// Expected error messages
await page.getByText('Errors exist for one or more form fields').waitFor();
// Close the form
await page.getByRole('button', { name: 'Cancel' }).click();
});

View File

@ -15,7 +15,7 @@ test('Quick Command', async ({ page }) => {
await page.waitForURL('**/platform/dashboard');
// Open Spotlight with Button
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page.getByRole('button', { name: 'Home Go to the home page' }).click();
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
@ -35,7 +35,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await doQuickLogin(page);
// Open Spotlight with Button
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page.getByRole('button', { name: 'Home Go to the home page' }).click();
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
@ -43,7 +43,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.waitForURL('**/platform');
// Use navigation menu
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', { name: 'Open Navigation Open the main' })
.click();
@ -56,7 +56,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.keyboard.press('Escape');
// use server info
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'Server Information About this Inventree instance'
@ -68,7 +68,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.waitForURL('**/platform');
// use license info
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'License Information Licenses for dependencies of the service'
@ -80,7 +80,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.getByLabel('License Information').getByRole('button').click();
// use about
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
.click();
@ -89,7 +89,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.getByLabel('About InvenTree').getByRole('button').click();
// use documentation
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'Documentation Visit the documentation to learn more about InvenTree'
@ -105,7 +105,7 @@ test('Quick Command - No Keys', async ({ page }) => {
/*
await page.getByPlaceholder('Search...').fill('secret');
await page.getByRole('button', { name: 'Secret action It was' }).click();
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
await page.getByPlaceholder('Search...').fill('Another secret action');
await page
.getByRole('button', {
@ -113,7 +113,7 @@ test('Quick Command - No Keys', async ({ page }) => {
})
.click();
await page.getByRole('tab', { name: 'Home' }).click();
await page.getByRole('button', { name: 'Open spotlight' }).click();
await page.getByLabel('open-spotlight').click();
*/
await page.getByPlaceholder('Search...').fill('secret');
await page.getByText('Nothing found...').click();

View File

@ -1,100 +0,0 @@
import { test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
test('Stock', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/stock/location/index/`);
await page.waitForURL('**/platform/stock/location/**');
await page.getByRole('tab', { name: 'Location Details' }).click();
await page.waitForURL('**/platform/stock/location/index/details');
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByText('1551ABK').first().click();
await page.getByRole('tab', { name: 'Stock', exact: true }).click();
await page.waitForURL('**/platform/stock/**');
await page.getByRole('tab', { name: 'Stock Locations' }).click();
await page.getByRole('cell', { name: 'Electronics Lab' }).first().click();
await page.getByRole('tab', { name: 'Default Parts' }).click();
await page.getByRole('tab', { name: 'Stock Locations' }).click();
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByRole('tab', { name: 'Location Details' }).click();
await page.goto(`${baseUrl}/stock/item/1194/details`);
await page.getByText('D.123 | Doohickey').waitFor();
await page.getByText('Batch Code: BX-123-2024-2-7').waitFor();
await page.getByRole('tab', { name: 'Stock Tracking' }).click();
await page.getByRole('tab', { name: 'Test Data' }).click();
await page.getByText('395c6d5586e5fb656901d047be27e1f7').waitFor();
await page.getByRole('tab', { name: 'Installed Items' }).click();
});
test('Purchasing', async ({ page }) => {
await doQuickLogin(page);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('cell', { name: 'PO0012' }).click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByRole('tab', { name: 'Received Stock' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByText('Arrow', { exact: true }).click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Supplied Parts' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByText('AVX Corporation').click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('cell', { name: 'West Branch' }).click();
await page.locator('.mantine-ScrollArea-root').click();
await page
.getByRole('row', { name: 'West Branch Yes Surf Avenue 9' })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('text-field-title').waitFor();
await page.getByLabel('text-field-line2').waitFor();
// Read the current value of the cell, to ensure we always *change* it!
const value = await page.getByLabel('text-field-line2').inputValue();
await page
.getByLabel('text-field-line2')
.fill(value == 'old' ? 'new' : 'old');
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Details' }).waitFor();
});
test('Stock Location Tree', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/stock/location/index/`);
await page.waitForURL('**/platform/stock/location/**');
await page.getByRole('tab', { name: 'Location Details' }).click();
await page.getByLabel('nav-breadcrumb-action').click();
await page.getByLabel('nav-tree-toggle-1}').click();
await page.getByLabel('nav-tree-item-2').click();
await page.getByLabel('breadcrumb-2-storage-room-a').waitFor();
await page.getByLabel('breadcrumb-1-factory').click();
await page.getByRole('cell', { name: 'Factory' }).first().waitFor();
});