mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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:
		| @@ -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, | ||||||
|  |                 part=self.part, | ||||||
|  |                 build=self, | ||||||
|  |                 batch=batch, | ||||||
|  |                 is_building=True | ||||||
|  |             ) | ||||||
|  |  | ||||||
|                 if serials: |             for output in outputs: | ||||||
|                     serial = serials[ii] |                 # Generate a new historical tracking entry | ||||||
|                 else: |                 if entry := output.add_tracking_entry( | ||||||
|                     serial = None |                     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) | ||||||
|  |  | ||||||
|                 output = stock.models.StockItem.objects.create( |                 # Auto-allocate stock based on serial number | ||||||
|                     quantity=1, |                 if auto_allocate: | ||||||
|                     location=location, |                     allocations = [] | ||||||
|                     part=self.part, |  | ||||||
|                     build=self, |  | ||||||
|                     batch=batch, |  | ||||||
|                     serial=serial, |  | ||||||
|                     is_building=True, |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 _add_tracking_entry(output, user) |                     for bom_item in trackable_parts: | ||||||
|  |                         valid_part_ids = valid_parts.get(bom_item.pk, []) | ||||||
|                 if auto_allocate and serial is not None: |  | ||||||
|  |  | ||||||
|                     # 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() |  | ||||||
|  |  | ||||||
|                         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( | ||||||
|                                     build_line=build_line, |                                     BuildItem( | ||||||
|                                     stock_item=stock_item, |                                         build_line=build_line, | ||||||
|                                     quantity=1, |                                         stock_item=stock_item, | ||||||
|                                     install_into=output, |                                         quantity=1, | ||||||
|  |                                         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: |                     invalid = part.find_conflicting_serial_numbers(serials) | ||||||
|                         part.validate_serial_number(serial, raise_error=True) |                 except DjangoValidationError as exc: | ||||||
|                     except DjangoValidationError as exc: |                     errors.append(exc.message) | ||||||
|                         # Catch raised error to extract specific error information |  | ||||||
|                         invalid.append(serial) |  | ||||||
|  |  | ||||||
|                         if exc.message not in errors: |                 if len(invalid) > 0: | ||||||
|                             errors.append(exc.message) |  | ||||||
|  |  | ||||||
|                 if len(errors) > 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,34 +1038,43 @@ 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( | ||||||
|                 response_data, |             response_data, | ||||||
|                 status=status.HTTP_201_CREATED, |             status=status.HTTP_201_CREATED, | ||||||
|                 headers=self.get_success_headers(serializer.data), |             headers=self.get_success_headers(serializer.data), | ||||||
|             ) |         ) | ||||||
|  |  | ||||||
|     def get_queryset(self, *args, **kwargs): |     def get_queryset(self, *args, **kwargs): | ||||||
|         """Annotate queryset before returning.""" |         """Annotate queryset before returning.""" | ||||||
|   | |||||||
| @@ -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) |             serial_int = 0 | ||||||
|  |  | ||||||
|             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 |  | ||||||
|  |  | ||||||
|         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,7 +1570,10 @@ class StockItem( | |||||||
|             deltas=deltas, |             deltas=deltas, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         entry.save() |         if commit: | ||||||
|  |             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): | ||||||
| @@ -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: |             InvenTree.tasks.offload_task( | ||||||
|                 StockItem.objects.partial_rebuild(tree_id=tree_id) |                 stock.tasks.rebuild_stock_item_tree, tree_id=tree_id | ||||||
|         except Exception: |             ) | ||||||
|             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(); |  | ||||||
| }); |  | ||||||
		Reference in New Issue
	
	Block a user