mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 19:46:46 +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:
parent
f77c8a5b5b
commit
7443d21854
@ -487,6 +487,11 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError([_('Invalid quantity provided')])
|
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 ''
|
input_string = str(input_string).strip() if input_string else ''
|
||||||
|
|
||||||
if len(input_string) == 0:
|
if len(input_string) == 0:
|
||||||
|
@ -1101,7 +1101,7 @@ COOKIE_MODE = (
|
|||||||
# Valid modes (as per the django settings documentation)
|
# Valid modes (as per the django settings documentation)
|
||||||
valid_cookie_modes = ['lax', 'strict', 'none']
|
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)
|
# Set the cookie mode (in production mode only)
|
||||||
COOKIE_MODE = COOKIE_MODE.capitalize()
|
COOKIE_MODE = COOKIE_MODE.capitalize()
|
||||||
else:
|
else:
|
||||||
|
@ -263,7 +263,9 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
|||||||
MAX_QUERY_TIME = 7.5
|
MAX_QUERY_TIME = 7.5
|
||||||
|
|
||||||
@contextmanager
|
@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.
|
"""Context manager to check that the number of queries is less than a certain value.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@ -281,6 +283,12 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
|||||||
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
|
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
|
||||||
) # pragma: no cover
|
) # 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:
|
if verbose and n >= value:
|
||||||
msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover
|
msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover
|
||||||
else:
|
else:
|
||||||
|
@ -834,6 +834,16 @@ class Build(
|
|||||||
location: Override location
|
location: Override location
|
||||||
auto_allocate: Automatically allocate stock with matching serial numbers
|
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)
|
user = kwargs.get('user', None)
|
||||||
batch = kwargs.get('batch', self.batch)
|
batch = kwargs.get('batch', self.batch)
|
||||||
location = kwargs.get('location', None)
|
location = kwargs.get('location', None)
|
||||||
@ -843,81 +853,51 @@ class Build(
|
|||||||
if location is None:
|
if location is None:
|
||||||
location = self.destination or self.part.get_default_location()
|
location = self.destination or self.part.get_default_location()
|
||||||
|
|
||||||
"""
|
# We are generating multiple serialized outputs
|
||||||
Determine if we can create a single output (with quantity > 0),
|
if serials or self.part.has_trackable_parts:
|
||||||
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:
|
|
||||||
"""Create multiple build outputs with a single quantity of 1."""
|
"""Create multiple build outputs with a single quantity of 1."""
|
||||||
|
|
||||||
# Quantity *must* be an integer at this point!
|
# Create tracking entries for each item
|
||||||
quantity = int(quantity)
|
tracking = []
|
||||||
|
allocations = []
|
||||||
|
|
||||||
for ii in range(quantity):
|
outputs = stock.models.StockItem._create_serial_numbers(
|
||||||
|
serials,
|
||||||
if serials:
|
|
||||||
serial = serials[ii]
|
|
||||||
else:
|
|
||||||
serial = None
|
|
||||||
|
|
||||||
output = stock.models.StockItem.objects.create(
|
|
||||||
quantity=1,
|
|
||||||
location=location,
|
|
||||||
part=self.part,
|
part=self.part,
|
||||||
build=self,
|
build=self,
|
||||||
batch=batch,
|
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 trackable_parts:
|
||||||
|
valid_part_ids = valid_parts.get(bom_item.pk, [])
|
||||||
for bom_item in self.part.get_trackable_parts():
|
|
||||||
|
|
||||||
parts = bom_item.get_valid_parts_for_allocation()
|
|
||||||
|
|
||||||
items = stock.models.StockItem.objects.filter(
|
items = stock.models.StockItem.objects.filter(
|
||||||
part__in=parts,
|
part__pk__in=valid_part_ids,
|
||||||
serial=str(serial),
|
serial=output.serial,
|
||||||
quantity=1,
|
quantity=1,
|
||||||
).filter(stock.models.StockItem.IN_STOCK_FILTER)
|
).filter(stock.models.StockItem.IN_STOCK_FILTER)
|
||||||
|
|
||||||
"""
|
|
||||||
Test if there is a matching serial number!
|
|
||||||
"""
|
|
||||||
if items.exists() and items.count() == 1:
|
if items.exists() and items.count() == 1:
|
||||||
stock_item = items[0]
|
stock_item = items[0]
|
||||||
|
|
||||||
@ -929,15 +909,23 @@ class Build(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Allocate the stock items against the BuildLine
|
# Allocate the stock items against the BuildLine
|
||||||
BuildItem.objects.create(
|
allocations.append(
|
||||||
|
BuildItem(
|
||||||
build_line=build_line,
|
build_line=build_line,
|
||||||
stock_item=stock_item,
|
stock_item=stock_item,
|
||||||
quantity=1,
|
quantity=1,
|
||||||
install_into=output,
|
install_into=output,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
except BuildLine.DoesNotExist:
|
except BuildLine.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Bulk create tracking entries
|
||||||
|
stock.models.StockItemTracking.objects.bulk_create(tracking)
|
||||||
|
|
||||||
|
# Generate stock allocations
|
||||||
|
BuildItem.objects.bulk_create(allocations)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
"""Create a single build output of the given quantity."""
|
"""Create a single build output of the given quantity."""
|
||||||
|
|
||||||
@ -950,7 +938,16 @@ class Build(
|
|||||||
is_building=True
|
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:
|
if self.status == BuildStatus.PENDING:
|
||||||
self.status = BuildStatus.PRODUCTION.value
|
self.status = BuildStatus.PRODUCTION.value
|
||||||
|
@ -404,11 +404,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Check for conflicting serial numbesr
|
# Check for conflicting serial numbesr
|
||||||
existing = []
|
existing = part.find_conflicting_serial_numbers(self.serials)
|
||||||
|
|
||||||
for serial in self.serials:
|
|
||||||
if not part.validate_serial_number(serial):
|
|
||||||
existing.append(serial)
|
|
||||||
|
|
||||||
if len(existing) > 0:
|
if len(existing) > 0:
|
||||||
|
|
||||||
|
@ -833,12 +833,38 @@ class Part(
|
|||||||
# This serial number is perfectly valid
|
# This serial number is perfectly valid
|
||||||
return True
|
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."""
|
"""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 = []
|
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:
|
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)
|
conflicts.append(serial)
|
||||||
|
|
||||||
return conflicts
|
return conflicts
|
||||||
|
@ -922,12 +922,12 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
|
|||||||
data.update(self.clean_data(request.data))
|
data.update(self.clean_data(request.data))
|
||||||
|
|
||||||
quantity = data.get('quantity', None)
|
quantity = data.get('quantity', None)
|
||||||
|
location = data.get('location', None)
|
||||||
|
|
||||||
if quantity is None:
|
if quantity is None:
|
||||||
raise ValidationError({'quantity': _('Quantity is required')})
|
raise ValidationError({'quantity': _('Quantity is required')})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Part.objects.prefetch_related(None)
|
|
||||||
part = Part.objects.get(pk=data.get('part', None))
|
part = Part.objects.get(pk=data.get('part', None))
|
||||||
except (ValueError, Part.DoesNotExist):
|
except (ValueError, Part.DoesNotExist):
|
||||||
raise ValidationError({'part': _('Valid part must be supplied')})
|
raise ValidationError({'part': _('Valid part must be supplied')})
|
||||||
@ -951,15 +951,13 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
|
|||||||
serials = None
|
serials = None
|
||||||
|
|
||||||
# Check if a set of serial numbers was provided
|
# 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
|
# 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:
|
try:
|
||||||
supplier_part = SupplierPart.objects.get(
|
supplier_part = SupplierPart.objects.get(pk=supplier_part_id)
|
||||||
pk=data.get('supplier_part', None)
|
except Exception:
|
||||||
)
|
|
||||||
except (ValueError, SupplierPart.DoesNotExist):
|
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'supplier_part': _('The given supplier part does not exist')
|
'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
|
# Now remove the flag from data, so that it doesn't interfere with saving
|
||||||
# Do this regardless of results above
|
# Do this regardless of results above
|
||||||
if 'use_pack_size' in data:
|
data.pop('use_pack_size', None)
|
||||||
data.pop('use_pack_size')
|
|
||||||
|
|
||||||
# Assign serial numbers for a trackable part
|
# Assign serial numbers for a trackable part
|
||||||
if serial_numbers:
|
if serial_numbers:
|
||||||
@ -1011,22 +1008,20 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
|
|||||||
invalid = []
|
invalid = []
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for serial in serials:
|
|
||||||
try:
|
try:
|
||||||
part.validate_serial_number(serial, raise_error=True)
|
invalid = part.find_conflicting_serial_numbers(serials)
|
||||||
except DjangoValidationError as exc:
|
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)
|
errors.append(exc.message)
|
||||||
|
|
||||||
if len(errors) > 0:
|
if len(invalid) > 0:
|
||||||
msg = _('The following serial numbers already exist or are invalid')
|
msg = _('The following serial numbers already exist or are invalid')
|
||||||
msg += ' : '
|
msg += ' : '
|
||||||
msg += ','.join([str(e) for e in invalid])
|
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:
|
except DjangoValidationError as e:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
@ -1043,27 +1038,36 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Create an initial StockItem object
|
|
||||||
item = serializer.save()
|
|
||||||
|
|
||||||
if serials:
|
if serials:
|
||||||
# Assign the first serial number to the "master" item
|
# Create multiple serialized StockItem objects
|
||||||
item.serial = serials[0]
|
items = StockItem._create_serial_numbers(
|
||||||
|
serials, **serializer.validated_data
|
||||||
|
)
|
||||||
|
|
||||||
# Save the item (with user information)
|
# Next, bulk-create stock tracking entries for the newly created items
|
||||||
item.save(user=user)
|
tracking = []
|
||||||
|
|
||||||
if serials:
|
for item in items:
|
||||||
for serial in serials[1:]:
|
if entry := item.add_tracking_entry(
|
||||||
# Create a duplicate stock item with the next serial number
|
StockHistoryCode.CREATED,
|
||||||
item.pk = None
|
user,
|
||||||
item.serial = serial
|
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}
|
response_data = {'quantity': quantity, 'serial_numbers': serials}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
# Create a single StockItem object
|
||||||
|
# Note: This automatically creates a tracking entry
|
||||||
|
item = serializer.save()
|
||||||
|
item.save(user=user)
|
||||||
|
|
||||||
response_data = serializer.data
|
response_data = serializer.data
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
|
@ -12,7 +12,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models, transaction
|
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.functions import Coalesce
|
||||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||||
from django.db.utils import IntegrityError, OperationalError
|
from django.db.utils import IntegrityError, OperationalError
|
||||||
@ -33,6 +33,7 @@ import InvenTree.ready
|
|||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
import report.mixins
|
import report.mixins
|
||||||
import report.models
|
import report.models
|
||||||
|
import stock.tasks
|
||||||
from build import models as BuildModels
|
from build import models as BuildModels
|
||||||
from common.icons import validate_icon
|
from common.icons import validate_icon
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
@ -459,6 +460,97 @@ class StockItem(
|
|||||||
& Q(expiry_date__lt=InvenTree.helpers.current_date())
|
& 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):
|
def update_serial_number(self):
|
||||||
"""Update the 'serial_int' field, to be an integer representation of the serial number.
|
"""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()
|
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
|
try:
|
||||||
# If a non-null value is returned (by any plugin) we will use that
|
serial_int = int(serial_int)
|
||||||
|
|
||||||
serial_int = None
|
if serial_int <= 0:
|
||||||
|
serial_int = 0
|
||||||
for plugin in registry.with_mixin('validation'):
|
except (ValueError, TypeError):
|
||||||
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
|
|
||||||
serial_int = 0
|
serial_int = 0
|
||||||
|
|
||||||
if serial not in [None, '']:
|
|
||||||
serial_int = InvenTree.helpers.extract_int(serial)
|
|
||||||
|
|
||||||
self.serial_int = serial_int
|
self.serial_int = serial_int
|
||||||
|
|
||||||
@ -1452,6 +1524,7 @@ class StockItem(
|
|||||||
user: User,
|
user: User,
|
||||||
deltas: dict | None = None,
|
deltas: dict | None = None,
|
||||||
notes: str = '',
|
notes: str = '',
|
||||||
|
commit: bool = True,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Add a history tracking entry for this StockItem.
|
"""Add a history tracking entry for this StockItem.
|
||||||
@ -1461,6 +1534,9 @@ class StockItem(
|
|||||||
user (User): The user performing this action
|
user (User): The user performing this action
|
||||||
deltas (dict, optional): A map of the changes made to the model. Defaults to None.
|
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 ''.
|
notes (str, optional): URL associated with this tracking entry. Defaults to ''.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StockItemTracking: The created tracking entry
|
||||||
"""
|
"""
|
||||||
if deltas is None:
|
if deltas is None:
|
||||||
deltas = {}
|
deltas = {}
|
||||||
@ -1471,7 +1547,7 @@ class StockItem(
|
|||||||
and len(deltas) == 0
|
and len(deltas) == 0
|
||||||
and not notes
|
and not notes
|
||||||
):
|
):
|
||||||
return
|
return None
|
||||||
|
|
||||||
# Has a location been specified?
|
# Has a location been specified?
|
||||||
location = kwargs.get('location')
|
location = kwargs.get('location')
|
||||||
@ -1485,7 +1561,7 @@ class StockItem(
|
|||||||
if quantity:
|
if quantity:
|
||||||
deltas['quantity'] = float(quantity)
|
deltas['quantity'] = float(quantity)
|
||||||
|
|
||||||
entry = StockItemTracking.objects.create(
|
entry = StockItemTracking(
|
||||||
item=self,
|
item=self,
|
||||||
tracking_type=entry_type.value,
|
tracking_type=entry_type.value,
|
||||||
user=user,
|
user=user,
|
||||||
@ -1494,8 +1570,11 @@ class StockItem(
|
|||||||
deltas=deltas,
|
deltas=deltas,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if commit:
|
||||||
entry.save()
|
entry.save()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def serializeStock(self, quantity, serials, user, notes='', location=None):
|
def serializeStock(self, quantity, serials, user, notes='', location=None):
|
||||||
"""Split this stock item into unique serial numbers.
|
"""Split this stock item into unique serial numbers.
|
||||||
@ -1536,7 +1615,7 @@ class StockItem(
|
|||||||
|
|
||||||
if type(serials) not in [list, tuple]:
|
if type(serials) not in [list, tuple]:
|
||||||
raise ValidationError({
|
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):
|
if quantity != len(serials):
|
||||||
@ -1552,45 +1631,54 @@ class StockItem(
|
|||||||
msg = _('Serial numbers already exist') + f': {exists}'
|
msg = _('Serial numbers already exist') + f': {exists}'
|
||||||
raise ValidationError({'serial_numbers': msg})
|
raise ValidationError({'serial_numbers': msg})
|
||||||
|
|
||||||
# Create a new stock item for each unique serial number
|
# Serialize this StockItem
|
||||||
for serial in serials:
|
data = dict(StockItem.objects.filter(pk=self.pk).values()[0])
|
||||||
# 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
|
|
||||||
|
|
||||||
if location:
|
if location:
|
||||||
new_item.location = location
|
data['location'] = location
|
||||||
|
|
||||||
# The item already has a transaction history, don't create a new note
|
data['part'] = self.part
|
||||||
new_item.save(user=user, notes=notes)
|
data['parent'] = self
|
||||||
|
data['tree_id'] = self.tree_id
|
||||||
|
|
||||||
# Copy entire transaction history
|
# Generate a new serial number for each item
|
||||||
new_item.copyHistoryFrom(self)
|
items = StockItem._create_serial_numbers(serials, **data)
|
||||||
|
|
||||||
# Copy test result history
|
# Create a new tracking entry for each item
|
||||||
new_item.copyTestResultsFrom(self)
|
history_items = []
|
||||||
|
|
||||||
# Create a new stock tracking item
|
for item in items:
|
||||||
new_item.add_tracking_entry(
|
if entry := item.add_tracking_entry(
|
||||||
StockHistoryCode.ASSIGNED_SERIAL,
|
StockHistoryCode.ASSIGNED_SERIAL,
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
deltas={'serial': serial},
|
deltas={'serial': item.serial},
|
||||||
location=location,
|
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
|
# Remove the equivalent number of items
|
||||||
self.take_stock(quantity, user, notes=notes)
|
self.take_stock(quantity, user, notes=notes)
|
||||||
|
|
||||||
# Rebuild the stock tree
|
# Rebuild the stock tree
|
||||||
try:
|
InvenTree.tasks.offload_task(
|
||||||
StockItem.objects.partial_rebuild(tree_id=self.tree_id)
|
stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id
|
||||||
except Exception:
|
)
|
||||||
logger.warning('Failed to rebuild stock tree during serializeStock')
|
|
||||||
StockItem.objects.rebuild()
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def copyHistoryFrom(self, other):
|
def copyHistoryFrom(self, other):
|
||||||
@ -1822,12 +1910,10 @@ class StockItem(
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
# Rebuild stock trees as required
|
# Rebuild stock trees as required
|
||||||
try:
|
|
||||||
for tree_id in tree_ids:
|
for tree_id in tree_ids:
|
||||||
StockItem.objects.partial_rebuild(tree_id=tree_id)
|
InvenTree.tasks.offload_task(
|
||||||
except Exception:
|
stock.tasks.rebuild_stock_item_tree, tree_id=tree_id
|
||||||
logger.warning('Rebuilding entire StockItem tree during merge_stock_items')
|
)
|
||||||
StockItem.objects.rebuild()
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def splitStock(self, quantity, location=None, user=None, **kwargs):
|
def splitStock(self, quantity, location=None, user=None, **kwargs):
|
||||||
@ -1922,11 +2008,9 @@ class StockItem(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Rebuild the tree for this parent item
|
# Rebuild the tree for this parent item
|
||||||
try:
|
InvenTree.tasks.offload_task(
|
||||||
StockItem.objects.partial_rebuild(tree_id=self.tree_id)
|
stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id
|
||||||
except Exception:
|
)
|
||||||
logger.warning('Rebuilding entire StockItem tree')
|
|
||||||
StockItem.objects.rebuild()
|
|
||||||
|
|
||||||
# Attempt to reload the new item from the database
|
# Attempt to reload the new item from the database
|
||||||
try:
|
try:
|
||||||
|
24
src/backend/InvenTree/stock/tasks.py
Normal file
24
src/backend/InvenTree/stock/tasks.py
Normal 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()
|
@ -2406,7 +2406,7 @@ class StockStatisticsTest(StockAPITestCase):
|
|||||||
|
|
||||||
fixtures = [*StockAPITestCase.fixtures, 'build']
|
fixtures = [*StockAPITestCase.fixtures, 'build']
|
||||||
|
|
||||||
def test_test_statics(self):
|
def test_test_statistics(self):
|
||||||
"""Test the test statistics API endpoints."""
|
"""Test the test statistics API endpoints."""
|
||||||
part = Part.objects.first()
|
part = Part.objects.first()
|
||||||
response = self.get(
|
response = self.get(
|
||||||
|
@ -13,6 +13,7 @@ export function SpotlightButton() {
|
|||||||
onClick={() => firstSpotlight.open()}
|
onClick={() => firstSpotlight.open()}
|
||||||
title={t`Open spotlight`}
|
title={t`Open spotlight`}
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
|
aria-label="open-spotlight"
|
||||||
>
|
>
|
||||||
<IconCommand />
|
<IconCommand />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
@ -103,7 +103,11 @@ export function Header() {
|
|||||||
<NavTabs />
|
<NavTabs />
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<ActionIcon onClick={openSearchDrawer} variant="transparent">
|
<ActionIcon
|
||||||
|
onClick={openSearchDrawer}
|
||||||
|
variant="transparent"
|
||||||
|
aria-label="open-search"
|
||||||
|
>
|
||||||
<IconSearch />
|
<IconSearch />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<SpotlightButton />
|
<SpotlightButton />
|
||||||
@ -119,6 +123,7 @@ export function Header() {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={openNotificationDrawer}
|
onClick={openNotificationDrawer}
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
|
aria-label="open-notifications"
|
||||||
>
|
>
|
||||||
<IconBell />
|
<IconBell />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
@ -68,7 +68,13 @@ function QueryResultGroup({
|
|||||||
const model = getModelInfo(query.model);
|
const model = getModelInfo(query.model);
|
||||||
|
|
||||||
return (
|
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}`}>
|
<Stack key={`stack-${query.model}`}>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group justify="left" gap={5} wrap="nowrap">
|
<Group justify="left" gap={5} wrap="nowrap">
|
||||||
@ -84,13 +90,14 @@ function QueryResultGroup({
|
|||||||
color="red"
|
color="red"
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
radius="xs"
|
radius="xs"
|
||||||
|
aria-label={`remove-search-group-${query.model}`}
|
||||||
onClick={() => onRemove(query.model)}
|
onClick={() => onRemove(query.model)}
|
||||||
>
|
>
|
||||||
<IconX />
|
<IconX />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Stack>
|
<Stack aria-label={`search-group-results-${query.model}`}>
|
||||||
{query.results.results.map((result: any) => (
|
{query.results.results.map((result: any) => (
|
||||||
<Anchor
|
<Anchor
|
||||||
onClick={(event: any) =>
|
onClick={(event: any) =>
|
||||||
@ -367,6 +374,7 @@ export function SearchDrawer({
|
|||||||
title={
|
title={
|
||||||
<Group justify="space-between" gap={1} wrap="nowrap">
|
<Group justify="space-between" gap={1} wrap="nowrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
aria-label="global-search-input"
|
||||||
placeholder={t`Enter search text`}
|
placeholder={t`Enter search text`}
|
||||||
radius="xs"
|
radius="xs"
|
||||||
value={value}
|
value={value}
|
||||||
|
@ -127,9 +127,14 @@ function BasePanelGroup({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Boundary label={`PanelGroup-${pageKey}`}>
|
<Boundary label={`PanelGroup-${pageKey}`}>
|
||||||
<Paper p="sm" radius="xs" shadow="xs">
|
<Paper p="sm" radius="xs" shadow="xs" aria-label={`${pageKey}`}>
|
||||||
<Tabs value={currentPanel} orientation="vertical" keepMounted={false}>
|
<Tabs
|
||||||
<Tabs.List justify="left">
|
value={currentPanel}
|
||||||
|
orientation="vertical"
|
||||||
|
keepMounted={false}
|
||||||
|
aria-label={`panel-group-${pageKey}`}
|
||||||
|
>
|
||||||
|
<Tabs.List justify="left" aria-label={`panel-tabs-${pageKey}`}>
|
||||||
{allPanels.map(
|
{allPanels.map(
|
||||||
(panel) =>
|
(panel) =>
|
||||||
!panel.hidden && (
|
!panel.hidden && (
|
||||||
|
@ -50,8 +50,10 @@ import { useGlobalSettingsState } from '../states/SettingsState';
|
|||||||
export function useStockFields({
|
export function useStockFields({
|
||||||
item_detail,
|
item_detail,
|
||||||
part_detail,
|
part_detail,
|
||||||
|
partId,
|
||||||
create = false
|
create = false
|
||||||
}: {
|
}: {
|
||||||
|
partId?: number;
|
||||||
item_detail?: any;
|
item_detail?: any;
|
||||||
part_detail?: any;
|
part_detail?: any;
|
||||||
create: boolean;
|
create: boolean;
|
||||||
@ -81,7 +83,7 @@ export function useStockFields({
|
|||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const fields: ApiFormFieldSet = {
|
const fields: ApiFormFieldSet = {
|
||||||
part: {
|
part: {
|
||||||
value: part,
|
value: partId,
|
||||||
disabled: !create,
|
disabled: !create,
|
||||||
filters: {
|
filters: {
|
||||||
active: create ? true : undefined
|
active: create ? true : undefined
|
||||||
@ -201,7 +203,8 @@ export function useStockFields({
|
|||||||
batchCode,
|
batchCode,
|
||||||
serialNumbers,
|
serialNumbers,
|
||||||
trackable,
|
trackable,
|
||||||
create
|
create,
|
||||||
|
partId
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -401,7 +401,7 @@ export function StockItemTable({
|
|||||||
};
|
};
|
||||||
}, [table]);
|
}, [table]);
|
||||||
|
|
||||||
const stockItemFields = useStockFields({ create: true });
|
const stockItemFields = useStockFields({ create: true, partId: params.part });
|
||||||
|
|
||||||
const newStockItem = useCreateApiFormModal({
|
const newStockItem = useCreateApiFormModal({
|
||||||
url: ApiEndpoints.stock_item_list,
|
url: ApiEndpoints.stock_item_list,
|
||||||
|
@ -5,7 +5,7 @@ test('Modals as admin', async ({ page }) => {
|
|||||||
await doQuickLogin(page, 'admin', 'inventree');
|
await doQuickLogin(page, 'admin', 'inventree');
|
||||||
|
|
||||||
// use server info
|
// use server info
|
||||||
await page.getByRole('button', { name: 'Open spotlight' }).click();
|
await page.getByLabel('open-spotlight').click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
name: 'Server Information About this Inventree instance'
|
name: 'Server Information About this Inventree instance'
|
||||||
@ -17,7 +17,7 @@ test('Modals as admin', async ({ page }) => {
|
|||||||
await page.waitForURL('**/platform/home');
|
await page.waitForURL('**/platform/home');
|
||||||
|
|
||||||
// use license info
|
// use license info
|
||||||
await page.getByRole('button', { name: 'Open spotlight' }).click();
|
await page.getByLabel('open-spotlight').click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
name: 'License Information Licenses for dependencies of the service'
|
name: 'License Information Licenses for dependencies of the service'
|
||||||
@ -44,7 +44,7 @@ test('Modals as admin', async ({ page }) => {
|
|||||||
.click();
|
.click();
|
||||||
|
|
||||||
// use about
|
// use about
|
||||||
await page.getByRole('button', { name: 'Open spotlight' }).click();
|
await page.getByLabel('open-spotlight').click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
|
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
|
||||||
.click();
|
.click();
|
||||||
|
@ -178,3 +178,53 @@ test('Purchase Orders - Barcodes', async ({ page }) => {
|
|||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
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();
|
||||||
|
});
|
||||||
|
104
src/frontend/tests/pages/pui_stock.spec.ts
Normal file
104
src/frontend/tests/pages/pui_stock.spec.ts
Normal 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();
|
||||||
|
});
|
@ -15,7 +15,7 @@ test('Quick Command', async ({ page }) => {
|
|||||||
await page.waitForURL('**/platform/dashboard');
|
await page.waitForURL('**/platform/dashboard');
|
||||||
|
|
||||||
// Open Spotlight with Button
|
// 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('button', { name: 'Home Go to the home page' }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
|
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
|
||||||
@ -35,7 +35,7 @@ test('Quick Command - No Keys', async ({ page }) => {
|
|||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
// Open Spotlight with Button
|
// 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('button', { name: 'Home Go to the home page' }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
|
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
|
||||||
@ -43,7 +43,7 @@ test('Quick Command - No Keys', async ({ page }) => {
|
|||||||
await page.waitForURL('**/platform');
|
await page.waitForURL('**/platform');
|
||||||
|
|
||||||
// Use navigation menu
|
// Use navigation menu
|
||||||
await page.getByRole('button', { name: 'Open spotlight' }).click();
|
await page.getByLabel('open-spotlight').click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'Open Navigation Open the main' })
|
.getByRole('button', { name: 'Open Navigation Open the main' })
|
||||||
.click();
|
.click();
|
||||||
@ -56,7 +56,7 @@ test('Quick Command - No Keys', async ({ page }) => {
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// use server info
|
// use server info
|
||||||
await page.getByRole('button', { name: 'Open spotlight' }).click();
|
await page.getByLabel('open-spotlight').click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
name: 'Server Information About this Inventree instance'
|
name: 'Server Information About this Inventree instance'
|
||||||
@ -68,7 +68,7 @@ test('Quick Command - No Keys', async ({ page }) => {
|
|||||||
await page.waitForURL('**/platform');
|
await page.waitForURL('**/platform');
|
||||||
|
|
||||||
// use license info
|
// use license info
|
||||||
await page.getByRole('button', { name: 'Open spotlight' }).click();
|
await page.getByLabel('open-spotlight').click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
name: 'License Information Licenses for dependencies of the service'
|
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();
|
await page.getByLabel('License Information').getByRole('button').click();
|
||||||
|
|
||||||
// use about
|
// use about
|
||||||
await page.getByRole('button', { name: 'Open spotlight' }).click();
|
await page.getByLabel('open-spotlight').click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
|
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
|
||||||
.click();
|
.click();
|
||||||
@ -89,7 +89,7 @@ test('Quick Command - No Keys', async ({ page }) => {
|
|||||||
await page.getByLabel('About InvenTree').getByRole('button').click();
|
await page.getByLabel('About InvenTree').getByRole('button').click();
|
||||||
|
|
||||||
// use documentation
|
// use documentation
|
||||||
await page.getByRole('button', { name: 'Open spotlight' }).click();
|
await page.getByLabel('open-spotlight').click();
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
name: 'Documentation Visit the documentation to learn more about InvenTree'
|
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.getByPlaceholder('Search...').fill('secret');
|
||||||
await page.getByRole('button', { name: 'Secret action It was' }).click();
|
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.getByPlaceholder('Search...').fill('Another secret action');
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
@ -113,7 +113,7 @@ test('Quick Command - No Keys', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole('tab', { name: 'Home' }).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.getByPlaceholder('Search...').fill('secret');
|
||||||
await page.getByText('Nothing found...').click();
|
await page.getByText('Nothing found...').click();
|
||||||
|
@ -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();
|
|
||||||
});
|
|
Loading…
x
Reference in New Issue
Block a user