mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	[refactor] PO receive fix (#10174)
* Enhance 'count_queries' helper * Add threshold * Update typing * Better rendering * Improve StockItem - Make model operations more efficient * improve with_mixin - Cache config map against the session cache * Refactor receive_line_item - Pass multiple line items in simultaneously - DB query optimization - Use bulk_create and bulk_update operations * Remove extraneous save call * Fix for unit tests * Fix return type * Fix serializer return type * Refactor part pricing updates * UI tweaks * Use bulk_create * Refactor API and endpoints * Bump API version * Fix unit tests * Fix playwright tests * Remove debug msg * Fix for table filter hover * Adjust unit test
This commit is contained in:
		| @@ -1,11 +1,15 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 384 | ||||
| INVENTREE_API_VERSION = 385 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
| v385 -> 2025-08-15 : https://github.com/inventree/InvenTree/pull/10174 | ||||
|     - Adjust return type of PurchaseOrderReceive API serializer | ||||
|     - Now returns list of of the created stock items when receiving | ||||
|  | ||||
| v384 -> 2025-08-08 : https://github.com/inventree/InvenTree/pull/9969 | ||||
|     - Bump allauth | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
|  | ||||
| from datetime import datetime | ||||
| from string import Formatter | ||||
| from typing import Optional | ||||
|  | ||||
| from django.contrib.auth import get_user_model | ||||
| from django.core.exceptions import ValidationError | ||||
| @@ -1154,12 +1155,16 @@ class InvenTreeBarcodeMixin(models.Model): | ||||
|         return self.format_barcode() | ||||
|  | ||||
|     @classmethod | ||||
|     def lookup_barcode(cls, barcode_hash): | ||||
|     def lookup_barcode(cls, barcode_hash: str) -> models.Model: | ||||
|         """Check if a model instance exists with the specified third-party barcode hash.""" | ||||
|         return cls.objects.filter(barcode_hash=barcode_hash).first() | ||||
|  | ||||
|     def assign_barcode( | ||||
|         self, barcode_hash=None, barcode_data=None, raise_error=True, save=True | ||||
|         self, | ||||
|         barcode_hash: Optional[str] = None, | ||||
|         barcode_data: Optional[str] = None, | ||||
|         raise_error: bool = True, | ||||
|         save: bool = True, | ||||
|     ): | ||||
|         """Assign an external (third-party) barcode to this object.""" | ||||
|         # Must provide either barcode_hash or barcode_data | ||||
|   | ||||
| @@ -28,7 +28,10 @@ from plugin.models import PluginConfig | ||||
|  | ||||
| @contextmanager | ||||
| def count_queries( | ||||
|     msg: Optional[str] = None, log_to_file: bool = False, using: str = 'default' | ||||
|     msg: Optional[str] = None, | ||||
|     log_to_file: bool = False, | ||||
|     using: str = 'default', | ||||
|     threshold: int = 10, | ||||
| ):  # pragma: no cover | ||||
|     """Helper function to count the number of queries executed. | ||||
|  | ||||
| @@ -36,10 +39,15 @@ def count_queries( | ||||
|         msg: Optional message to print after counting queries | ||||
|         log_to_file: If True, log the queries to a file (default = False) | ||||
|         using: The database connection to use (default = 'default') | ||||
|         threshold: Minimum number of queries to log (default = 10) | ||||
|     """ | ||||
|     t1 = time.time() | ||||
|  | ||||
|     with CaptureQueriesContext(connections[using]) as context: | ||||
|         yield | ||||
|  | ||||
|     dt = time.time() - t1 | ||||
|  | ||||
|     n = len(context.captured_queries) | ||||
|  | ||||
|     if log_to_file: | ||||
| @@ -47,10 +55,13 @@ def count_queries( | ||||
|             for q in context.captured_queries: | ||||
|                 f.write(str(q['sql']) + '\n\n') | ||||
|  | ||||
|     if msg: | ||||
|         print(f'{msg}: Executed {n} queries') | ||||
|     else: | ||||
|         print(f'Executed {n} queries') | ||||
|     output = f'Executed {n} queries in {dt:.4f}s' | ||||
|  | ||||
|     if threshold and n >= threshold: | ||||
|         if msg: | ||||
|             print(f'{msg}: {output}') | ||||
|         else: | ||||
|             print(output) | ||||
|  | ||||
|  | ||||
| def addUserPermission(user: User, app_name: str, model_name: str, perm: str) -> None: | ||||
|   | ||||
| @@ -6,7 +6,7 @@ from django.contrib.auth.models import User | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.core.validators import MinValueValidator | ||||
| from django.db import models, transaction | ||||
| from django.db.models import F, Q, Sum | ||||
| from django.db.models import F, Q, QuerySet, Sum | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.db.models.signals import post_save | ||||
| from django.dispatch.dispatcher import receiver | ||||
| @@ -870,7 +870,7 @@ class Build( | ||||
|         allocations.delete() | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def create_build_output(self, quantity, **kwargs) -> list[stock.models.StockItem]: | ||||
|     def create_build_output(self, quantity, **kwargs) -> QuerySet: | ||||
|         """Create a new build output against this BuildOrder. | ||||
|  | ||||
|         Arguments: | ||||
| @@ -883,7 +883,7 @@ class Build( | ||||
|             auto_allocate: Automatically allocate stock with matching serial numbers | ||||
|  | ||||
|         Returns: | ||||
|             A list of the created output (StockItem) objects. | ||||
|             A QuerySet of the created output (StockItem) objects. | ||||
|         """ | ||||
|         trackable_parts = self.part.get_trackable_parts() | ||||
|  | ||||
|   | ||||
| @@ -15,7 +15,7 @@ import rest_framework.serializers | ||||
| from django_filters import rest_framework as rest_filters | ||||
| from django_ical.views import ICalFeed | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import extend_schema_field | ||||
| from drf_spectacular.utils import extend_schema, extend_schema_field | ||||
| from rest_framework import status | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| @@ -24,6 +24,7 @@ import common.models | ||||
| import common.settings | ||||
| import company.models | ||||
| import stock.models as stock_models | ||||
| import stock.serializers as stock_serializers | ||||
| from data_exporter.mixins import DataExportViewMixin | ||||
| from generic.states.api import StatusView | ||||
| from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView | ||||
| @@ -472,6 +473,7 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI): | ||||
|     serializer_class = serializers.PurchaseOrderIssueSerializer | ||||
|  | ||||
|  | ||||
| @extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)}) | ||||
| class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI): | ||||
|     """API endpoint to receive stock items against a PurchaseOrder. | ||||
|  | ||||
| @@ -489,9 +491,18 @@ class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI): | ||||
|     """ | ||||
|  | ||||
|     queryset = models.PurchaseOrderLineItem.objects.none() | ||||
|  | ||||
|     serializer_class = serializers.PurchaseOrderReceiveSerializer | ||||
|  | ||||
|     def create(self, request, *args, **kwargs): | ||||
|         """Override the create method to handle stock item creation.""" | ||||
|         serializer = self.get_serializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         items = serializer.save() | ||||
|         queryset = stock_serializers.StockItemSerializer.annotate_queryset(items) | ||||
|         response = stock_serializers.StockItemSerializer(queryset, many=True) | ||||
|  | ||||
|         return Response(response.data, status=status.HTTP_201_CREATED) | ||||
|  | ||||
|  | ||||
| class PurchaseOrderLineItemFilter(LineItemFilter): | ||||
|     """Custom filters for the PurchaseOrderLineItemList endpoint.""" | ||||
|   | ||||
| @@ -7,7 +7,7 @@ from django.contrib.auth.models import User | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.core.validators import MinValueValidator | ||||
| from django.db import models, transaction | ||||
| from django.db.models import F, Q, Sum | ||||
| from django.db.models import F, Q, QuerySet, Sum | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.db.models.signals import post_save | ||||
| from django.dispatch.dispatcher import receiver | ||||
| @@ -782,10 +782,16 @@ class PurchaseOrder(TotalPriceMixin, Order): | ||||
|  | ||||
|             self.save() | ||||
|  | ||||
|             unique_parts = set() | ||||
|  | ||||
|             # Schedule pricing update for any referenced parts | ||||
|             for line in self.lines.all(): | ||||
|             for line in self.lines.all().prefetch_related('part__part'): | ||||
|                 # Ensure we only check 'unique' parts | ||||
|                 if line.part and line.part.part: | ||||
|                     line.part.part.schedule_pricing_update(create=True) | ||||
|                     unique_parts.add(line.part.part) | ||||
|  | ||||
|             for part in unique_parts: | ||||
|                 part.schedule_pricing_update(create=True, refresh=False) | ||||
|  | ||||
|             trigger_event(PurchaseOrderEvents.COMPLETED, id=self.pk) | ||||
|  | ||||
| @@ -915,6 +921,263 @@ class PurchaseOrder(TotalPriceMixin, Order): | ||||
|         """Return True if all line items have been received.""" | ||||
|         return self.pending_line_items().count() == 0 | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def receive_line_items( | ||||
|         self, location, items: list, user: User, **kwargs | ||||
|     ) -> QuerySet: | ||||
|         """Receive multiple line items against this PurchaseOrder. | ||||
|  | ||||
|         Arguments: | ||||
|             location: The StockLocation to receive the items into | ||||
|             items: A list of line item IDs and quantities to receive | ||||
|             user: The User performing the action | ||||
|  | ||||
|         Returns: | ||||
|             A QuerySet of the newly created StockItem objects | ||||
|  | ||||
|         The 'items' list values contain: | ||||
|             line_item: The PurchaseOrderLineItem instance | ||||
|             quantity: The quantity of items to receive | ||||
|             location: The location to receive the item into (optional) | ||||
|             status: The 'status' of the item | ||||
|             barcode: Optional barcode for the item (optional) | ||||
|             batch_code: Optional batch code for the item (optional) | ||||
|             expiry_date: Optional expiry date for the item (optional) | ||||
|             serials: Optional list of serial numbers (optional) | ||||
|             notes: Optional notes for the item (optional) | ||||
|         """ | ||||
|         if self.status != PurchaseOrderStatus.PLACED: | ||||
|             raise ValidationError( | ||||
|                 "Lines can only be received against an order marked as 'PLACED'" | ||||
|             ) | ||||
|  | ||||
|         # List of stock items which have been created | ||||
|         stock_items: list[stock.models.StockItem] = [] | ||||
|  | ||||
|         # List of stock items to bulk create | ||||
|         bulk_create_items: list[stock.models.StockItem] = [] | ||||
|  | ||||
|         # List of tracking entries to create | ||||
|         tracking_entries: list[stock.models.StockItemTracking] = [] | ||||
|  | ||||
|         # List of line items to update | ||||
|         line_items_to_update: list[PurchaseOrderLineItem] = [] | ||||
|  | ||||
|         convert_purchase_price = get_global_setting('PURCHASEORDER_CONVERT_CURRENCY') | ||||
|         default_currency = currency_code_default() | ||||
|  | ||||
|         # Prefetch line item objects for DB efficiency | ||||
|         line_items_ids = [item['line_item'].pk for item in items] | ||||
|  | ||||
|         line_items = PurchaseOrderLineItem.objects.filter( | ||||
|             pk__in=line_items_ids | ||||
|         ).prefetch_related('part', 'part__part', 'order') | ||||
|  | ||||
|         # Map order line items to their corresponding stock items | ||||
|         line_item_map = {line.pk: line for line in line_items} | ||||
|  | ||||
|         # Before we continue, validate that each line item is valid | ||||
|         # We validate this here because it is far more efficient, | ||||
|         # after we have fetched *all* line itemes in a single DB query | ||||
|         for line_item in line_item_map.values(): | ||||
|             if line_item.order != self: | ||||
|                 raise ValidationError({_('Line item does not match purchase order')}) | ||||
|  | ||||
|             if not line_item.part or not line_item.part.part: | ||||
|                 raise ValidationError({_('Line item is missing a linked part')}) | ||||
|  | ||||
|         for item in items: | ||||
|             # Extract required information | ||||
|             line_item_id = item['line_item'].pk | ||||
|  | ||||
|             line = line_item_map[line_item_id] | ||||
|  | ||||
|             quantity = item['quantity'] | ||||
|             barcode = item.get('barcode', '') | ||||
|  | ||||
|             try: | ||||
|                 if quantity < 0: | ||||
|                     raise ValidationError({ | ||||
|                         'quantity': _('Quantity must be a positive number') | ||||
|                     }) | ||||
|                 quantity = InvenTree.helpers.clean_decimal(quantity) | ||||
|             except TypeError: | ||||
|                 raise ValidationError({'quantity': _('Invalid quantity provided')}) | ||||
|  | ||||
|             supplier_part = line.part | ||||
|  | ||||
|             if not supplier_part: | ||||
|                 logger.warning( | ||||
|                     'Line item %s is missing a linked supplier part', line.pk | ||||
|                 ) | ||||
|                 continue | ||||
|  | ||||
|             base_part = supplier_part.part | ||||
|  | ||||
|             stock_location = item.get('location', location) or line.get_destination() | ||||
|  | ||||
|             # Calculate the received quantity in base part units | ||||
|             stock_quantity = supplier_part.base_quantity(quantity) | ||||
|  | ||||
|             # Calculate unit purchase price (in base units) | ||||
|             if line.purchase_price: | ||||
|                 purchase_price = line.purchase_price / supplier_part.base_quantity(1) | ||||
|  | ||||
|                 if convert_purchase_price: | ||||
|                     purchase_price = convert_money(purchase_price, default_currency) | ||||
|             else: | ||||
|                 purchase_price = None | ||||
|  | ||||
|             # Extract optional serial numbers | ||||
|             serials = item.get('serials', None) | ||||
|  | ||||
|             if serials and type(serials) is list and len(serials) > 0: | ||||
|                 serialize = True | ||||
|             else: | ||||
|                 serialize = False | ||||
|                 serials = [None] | ||||
|  | ||||
|             # Construct dataset for creating a new StockItem instances | ||||
|             stock_data = { | ||||
|                 'part': supplier_part.part, | ||||
|                 'supplier_part': supplier_part, | ||||
|                 'purchase_order': self, | ||||
|                 'purchase_price': purchase_price, | ||||
|                 'status': item.get('status', StockStatus.OK.value), | ||||
|                 'location': stock_location, | ||||
|                 'quantity': 1 if serialize else stock_quantity, | ||||
|                 'batch': item.get('batch_code', ''), | ||||
|                 'expiry_date': item.get('expiry_date', None), | ||||
|                 'packaging': item.get('packaging') or supplier_part.packaging, | ||||
|             } | ||||
|  | ||||
|             # Check linked build order | ||||
|             # This is for receiving against an *external* build order | ||||
|             if build_order := line.build_order: | ||||
|                 if not build_order.external: | ||||
|                     raise ValidationError( | ||||
|                         'Cannot receive items against an internal build order' | ||||
|                     ) | ||||
|  | ||||
|                 if build_order.part != base_part: | ||||
|                     raise ValidationError( | ||||
|                         'Cannot receive items against a build order for a different part' | ||||
|                     ) | ||||
|  | ||||
|                 if not stock_location and build_order.destination: | ||||
|                     # Override with the build order destination (if not specified) | ||||
|                     stock_data['location'] = stock_location = build_order.destination | ||||
|  | ||||
|                 if build_order.active: | ||||
|                     # An 'active' build order marks the items as "in production" | ||||
|                     stock_data['build'] = build_order | ||||
|                     stock_data['is_building'] = True | ||||
|                 elif build_order.status == BuildStatus.COMPLETE: | ||||
|                     # A 'completed' build order marks the items as "completed" | ||||
|                     stock_data['build'] = build_order | ||||
|                     stock_data['is_building'] = False | ||||
|  | ||||
|                     # Increase the 'completed' quantity for the build order | ||||
|                     build_order.completed += stock_quantity | ||||
|                     build_order.save() | ||||
|                 elif build_order.status == BuildStatus.CANCELLED: | ||||
|                     # A 'cancelled' build order is ignored | ||||
|                     pass | ||||
|                 else: | ||||
|                     # Un-handled state - raise an error | ||||
|                     raise ValidationError( | ||||
|                         "Cannot receive items against a build order in state '{build_order.status}'" | ||||
|                     ) | ||||
|  | ||||
|             # Now, create the new stock items | ||||
|             if serialize: | ||||
|                 stock_items.extend( | ||||
|                     stock.models.StockItem._create_serial_numbers( | ||||
|                         serials=serials, **stock_data | ||||
|                     ) | ||||
|                 ) | ||||
|             else: | ||||
|                 new_item = stock.models.StockItem( | ||||
|                     **stock_data, | ||||
|                     serial='', | ||||
|                     tree_id=stock.models.StockItem.getNextTreeID(), | ||||
|                     parent=None, | ||||
|                     level=0, | ||||
|                     lft=1, | ||||
|                     rght=2, | ||||
|                 ) | ||||
|  | ||||
|                 if barcode: | ||||
|                     new_item.assign_barcode(barcode_data=barcode, save=False) | ||||
|  | ||||
|                 # new_item.save() | ||||
|                 bulk_create_items.append(new_item) | ||||
|  | ||||
|             # Update the line item quantity | ||||
|             line.received += quantity | ||||
|             line_items_to_update.append(line) | ||||
|  | ||||
|         # Bulk create new stock items | ||||
|         if len(bulk_create_items) > 0: | ||||
|             stock.models.StockItem.objects.bulk_create(bulk_create_items) | ||||
|  | ||||
|             # Fetch them back again | ||||
|             tree_ids = [item.tree_id for item in bulk_create_items] | ||||
|  | ||||
|             created_items = stock.models.StockItem.objects.filter( | ||||
|                 tree_id__in=tree_ids, level=0, lft=1, rght=2, purchase_order=self | ||||
|             ).prefetch_related('location') | ||||
|  | ||||
|             stock_items.extend(created_items) | ||||
|  | ||||
|         # Generate a new tracking entry for each stock item | ||||
|         for item in stock_items: | ||||
|             tracking_entries.append( | ||||
|                 item.add_tracking_entry( | ||||
|                     StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, | ||||
|                     user, | ||||
|                     location=item.location, | ||||
|                     purchaseorder=self, | ||||
|                     quantity=float(item.quantity), | ||||
|                     commit=False, | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|         # Bulk create new tracking entries for each item | ||||
|         stock.models.StockItemTracking.objects.bulk_create(tracking_entries) | ||||
|  | ||||
|         # Update received quantity for each line item | ||||
|         PurchaseOrderLineItem.objects.bulk_update(line_items_to_update, ['received']) | ||||
|  | ||||
|         # Trigger an event for any interested plugins | ||||
|         trigger_event( | ||||
|             PurchaseOrderEvents.ITEM_RECEIVED, | ||||
|             order_id=self.pk, | ||||
|             item_ids=[item.pk for item in stock_items], | ||||
|         ) | ||||
|  | ||||
|         # Check to auto-complete the PurchaseOrder | ||||
|         if ( | ||||
|             get_global_setting('PURCHASEORDER_AUTO_COMPLETE', True) | ||||
|             and self.pending_line_count == 0 | ||||
|         ): | ||||
|             self.received_by = user | ||||
|             self.complete_order() | ||||
|  | ||||
|         # Send notification | ||||
|         notify_responsible( | ||||
|             self, | ||||
|             PurchaseOrder, | ||||
|             exclude=user, | ||||
|             content=InvenTreeNotificationBodies.ItemsReceived, | ||||
|             extra_users=line.part.part.get_subscribers(), | ||||
|         ) | ||||
|  | ||||
|         # Return a list of the created stock items | ||||
|         return stock.models.StockItem.objects.filter( | ||||
|             pk__in=[item.pk for item in stock_items] | ||||
|         ) | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def receive_line_item( | ||||
|         self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs | ||||
| @@ -934,182 +1197,24 @@ class PurchaseOrder(TotalPriceMixin, Order): | ||||
|             notes: Optional notes field for the StockItem | ||||
|             packaging: Optional packaging field for the StockItem | ||||
|             barcode: Optional barcode field for the StockItem | ||||
|             notify: If true, notify users of received items | ||||
|  | ||||
|         Raises: | ||||
|             ValidationError: If the quantity is negative or otherwise invalid | ||||
|             ValidationError: If the order is not in the 'PLACED' state | ||||
|         """ | ||||
|         # Extract optional batch code for the new stock item | ||||
|         batch_code = kwargs.get('batch_code', '') | ||||
|  | ||||
|         # Extract optional expiry date for the new stock item | ||||
|         expiry_date = kwargs.get('expiry_date') | ||||
|  | ||||
|         # Extract optional list of serial numbers | ||||
|         serials = kwargs.get('serials') | ||||
|  | ||||
|         # Extract optional notes field | ||||
|         notes = kwargs.get('notes', '') | ||||
|  | ||||
|         # Extract optional packaging field | ||||
|         packaging = kwargs.get('packaging') | ||||
|  | ||||
|         if not packaging: | ||||
|             # Default to the packaging field for the linked supplier part | ||||
|             if line.part: | ||||
|                 packaging = line.part.packaging | ||||
|  | ||||
|         # Extract optional barcode field | ||||
|         barcode = kwargs.get('barcode') | ||||
|  | ||||
|         # Prevent null values for barcode | ||||
|         if barcode is None: | ||||
|             barcode = '' | ||||
|  | ||||
|         if self.status != PurchaseOrderStatus.PLACED: | ||||
|             raise ValidationError( | ||||
|                 "Lines can only be received against an order marked as 'PLACED'" | ||||
|             ) | ||||
|  | ||||
|         try: | ||||
|             if quantity < 0: | ||||
|                 raise ValidationError({ | ||||
|                     'quantity': _('Quantity must be a positive number') | ||||
|                 }) | ||||
|             quantity = InvenTree.helpers.clean_decimal(quantity) | ||||
|         except TypeError: | ||||
|             raise ValidationError({'quantity': _('Invalid quantity provided')}) | ||||
|  | ||||
|         # Create a new stock item | ||||
|         if line.part and quantity > 0: | ||||
|             # Calculate received quantity in base units | ||||
|             stock_quantity = line.part.base_quantity(quantity) | ||||
|  | ||||
|             # Calculate unit purchase price (in base units) | ||||
|             if line.purchase_price: | ||||
|                 unit_purchase_price = line.purchase_price | ||||
|  | ||||
|                 # Convert purchase price to base units | ||||
|                 unit_purchase_price /= line.part.base_quantity(1) | ||||
|  | ||||
|                 # Convert to base currency | ||||
|                 if get_global_setting('PURCHASEORDER_CONVERT_CURRENCY'): | ||||
|                     try: | ||||
|                         unit_purchase_price = convert_money( | ||||
|                             unit_purchase_price, currency_code_default() | ||||
|                         ) | ||||
|                     except Exception: | ||||
|                         log_error('PurchaseOrder.receive_line_item') | ||||
|  | ||||
|             else: | ||||
|                 unit_purchase_price = None | ||||
|  | ||||
|             # Determine if we should individually serialize the items, or not | ||||
|             if type(serials) is list and len(serials) > 0: | ||||
|                 serialize = True | ||||
|             else: | ||||
|                 serialize = False | ||||
|                 serials = [None] | ||||
|  | ||||
|             # Construct dataset for receiving items | ||||
|             data = { | ||||
|                 'part': line.part.part, | ||||
|                 'supplier_part': line.part, | ||||
|                 'location': location, | ||||
|                 'quantity': 1 if serialize else stock_quantity, | ||||
|                 'purchase_order': self, | ||||
|                 'status': status, | ||||
|                 'batch': batch_code, | ||||
|                 'expiry_date': expiry_date, | ||||
|                 'packaging': packaging, | ||||
|                 'purchase_price': unit_purchase_price, | ||||
|             } | ||||
|  | ||||
|             if build_order := line.build_order: | ||||
|                 # Receiving items against an "external" build order | ||||
|  | ||||
|                 if not build_order.external: | ||||
|                     raise ValidationError( | ||||
|                         'Cannot receive items against an internal build order' | ||||
|                     ) | ||||
|  | ||||
|                 if build_order.part != data['part']: | ||||
|                     raise ValidationError( | ||||
|                         'Cannot receive items against a build order for a different part' | ||||
|                     ) | ||||
|  | ||||
|                 if not location and build_order.destination: | ||||
|                     # Override with the build order destination (if not specified) | ||||
|                     data['location'] = location = build_order.destination | ||||
|  | ||||
|                 if build_order.active: | ||||
|                     # An 'active' build order marks the items as "in production" | ||||
|                     data['build'] = build_order | ||||
|                     data['is_building'] = True | ||||
|                 elif build_order.status == BuildStatus.COMPLETE: | ||||
|                     # A 'completed' build order marks the items as "completed" | ||||
|                     data['build'] = build_order | ||||
|                     data['is_building'] = False | ||||
|  | ||||
|                     # Increase the 'completed' quantity for the build order | ||||
|                     build_order.completed += stock_quantity | ||||
|                     build_order.save() | ||||
|  | ||||
|                 elif build_order.status == BuildStatus.CANCELLED: | ||||
|                     # A 'cancelled' build order is ignored | ||||
|                     pass | ||||
|                 else: | ||||
|                     # Un-handled state - raise an error | ||||
|                     raise ValidationError( | ||||
|                         "Cannot receive items against a build order in state '{build_order.status}'" | ||||
|                     ) | ||||
|  | ||||
|             for sn in serials: | ||||
|                 item = stock.models.StockItem(serial=sn, **data) | ||||
|  | ||||
|                 # Assign the provided barcode | ||||
|                 if barcode: | ||||
|                     item.assign_barcode(barcode_data=barcode, save=False) | ||||
|  | ||||
|                 item.save(add_note=False) | ||||
|  | ||||
|                 tracking_info = {'status': status, 'purchaseorder': self.pk} | ||||
|  | ||||
|                 item.add_tracking_entry( | ||||
|                     StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, | ||||
|                     user, | ||||
|                     notes=notes, | ||||
|                     deltas=tracking_info, | ||||
|                     location=location, | ||||
|                     purchaseorder=self, | ||||
|                     quantity=float(quantity), | ||||
|                 ) | ||||
|  | ||||
|                 trigger_event( | ||||
|                     PurchaseOrderEvents.ITEM_RECEIVED, | ||||
|                     order_id=self.pk, | ||||
|                     item_id=item.pk, | ||||
|                     line_id=line.pk, | ||||
|                 ) | ||||
|  | ||||
|         # Update the number of parts received against the particular line item | ||||
|         # Note that this quantity does *not* take the pack_quantity into account, it is "number of packs" | ||||
|         line.received += quantity | ||||
|         line.save() | ||||
|  | ||||
|         # Has this order been completed? | ||||
|         if len(self.pending_line_items()) == 0: | ||||
|             if get_global_setting('PURCHASEORDER_AUTO_COMPLETE', True): | ||||
|                 self.received_by = user | ||||
|                 self.complete_order()  # This will save the model | ||||
|  | ||||
|         # Issue a notification to interested parties, that this order has been "updated" | ||||
|         notify_responsible( | ||||
|             self, | ||||
|             PurchaseOrder, | ||||
|             exclude=user, | ||||
|             content=InvenTreeNotificationBodies.ItemsReceived, | ||||
|             extra_users=line.part.part.get_subscribers(), | ||||
|         self.receive_line_items( | ||||
|             location, | ||||
|             [ | ||||
|                 { | ||||
|                     'line_item': line, | ||||
|                     'quantity': quantity, | ||||
|                     'location': location, | ||||
|                     'status': status, | ||||
|                     **kwargs, | ||||
|                 } | ||||
|             ], | ||||
|             user, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @@ -1582,8 +1687,11 @@ class OrderLineItem(InvenTree.models.InvenTreeMetadataModel): | ||||
|                 'reference': _('The order is locked and cannot be modified') | ||||
|             }) | ||||
|  | ||||
|         update_order = kwargs.pop('update_order', True) | ||||
|  | ||||
|         super().save(*args, **kwargs) | ||||
|         self.order.save() | ||||
|         if update_order and self.order: | ||||
|             self.order.save() | ||||
|  | ||||
|     def delete(self, *args, **kwargs): | ||||
|         """Custom delete method for the OrderLineItem model. | ||||
|   | ||||
| @@ -755,19 +755,11 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): | ||||
|  | ||||
|     line_item = serializers.PrimaryKeyRelatedField( | ||||
|         queryset=order.models.PurchaseOrderLineItem.objects.all(), | ||||
|         many=False, | ||||
|         allow_null=False, | ||||
|         required=True, | ||||
|         label=_('Line Item'), | ||||
|     ) | ||||
|  | ||||
|     def validate_line_item(self, item): | ||||
|         """Validation for the 'line_item' field.""" | ||||
|         if item.order != self.context['order']: | ||||
|             raise ValidationError(_('Line item does not match purchase order')) | ||||
|  | ||||
|         return item | ||||
|  | ||||
|     location = serializers.PrimaryKeyRelatedField( | ||||
|         queryset=stock.models.StockLocation.objects.all(), | ||||
|         many=False, | ||||
| @@ -864,19 +856,12 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): | ||||
|         quantity = data['quantity'] | ||||
|         serial_numbers = data.get('serial_numbers', '').strip() | ||||
|  | ||||
|         base_part = line_item.part.part | ||||
|         base_quantity = line_item.part.base_quantity(quantity) | ||||
|  | ||||
|         # Does the quantity need to be "integer" (for trackable parts?) | ||||
|         if base_part.trackable and Decimal(base_quantity) != int(base_quantity): | ||||
|             raise ValidationError({ | ||||
|                 'quantity': _( | ||||
|                     'An integer quantity must be provided for trackable parts' | ||||
|                 ) | ||||
|             }) | ||||
|  | ||||
|         # If serial numbers are provided | ||||
|         if serial_numbers: | ||||
|             supplier_part = line_item.part | ||||
|             base_part = supplier_part.part | ||||
|             base_quantity = supplier_part.base_quantity(quantity) | ||||
|  | ||||
|             try: | ||||
|                 # Pass the serial numbers through to the parent serializer once validated | ||||
|                 data['serials'] = extract_serial_numbers( | ||||
| @@ -940,6 +925,9 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): | ||||
|         if len(items) == 0: | ||||
|             raise ValidationError(_('Line items must be provided')) | ||||
|  | ||||
|         # Ensure barcodes are unique | ||||
|         unique_barcodes = set() | ||||
|  | ||||
|         # Check if the location is not specified for any particular item | ||||
|         for item in items: | ||||
|             line = item['line_item'] | ||||
| @@ -957,10 +945,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): | ||||
|                     'location': _('Destination location must be specified') | ||||
|                 }) | ||||
|  | ||||
|         # Ensure barcodes are unique | ||||
|         unique_barcodes = set() | ||||
|  | ||||
|         for item in items: | ||||
|             barcode = item.get('barcode', '') | ||||
|  | ||||
|             if barcode: | ||||
| @@ -971,7 +955,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): | ||||
|  | ||||
|         return data | ||||
|  | ||||
|     def save(self): | ||||
|     def save(self) -> list[stock.models.StockItem]: | ||||
|         """Perform the actual database transaction to receive purchase order items.""" | ||||
|         data = self.validated_data | ||||
|  | ||||
| @@ -983,33 +967,16 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): | ||||
|         # Location can be provided, or default to the order destination | ||||
|         location = data.get('location', order.destination) | ||||
|  | ||||
|         # Now we can actually receive the items into stock | ||||
|         with transaction.atomic(): | ||||
|             for item in items: | ||||
|                 # Select location (in descending order of priority) | ||||
|                 loc = ( | ||||
|                     item.get('location', None) | ||||
|                     or location | ||||
|                     or item['line_item'].get_destination() | ||||
|                 ) | ||||
|         try: | ||||
|             items = order.receive_line_items( | ||||
|                 location, items, request.user if request else None | ||||
|             ) | ||||
|         except (ValidationError, DjangoValidationError) as exc: | ||||
|             # Catch model errors and re-throw as DRF errors | ||||
|             raise ValidationError(detail=serializers.as_serializer_error(exc)) | ||||
|  | ||||
|                 try: | ||||
|                     order.receive_line_item( | ||||
|                         item['line_item'], | ||||
|                         loc, | ||||
|                         item['quantity'], | ||||
|                         request.user if request else None, | ||||
|                         status=item['status'], | ||||
|                         barcode=item.get('barcode', ''), | ||||
|                         batch_code=item.get('batch_code', ''), | ||||
|                         expiry_date=item.get('expiry_date', None), | ||||
|                         packaging=item.get('packaging', ''), | ||||
|                         serials=item.get('serials', None), | ||||
|                         notes=item.get('note', None), | ||||
|                     ) | ||||
|                 except (ValidationError, DjangoValidationError) as exc: | ||||
|                     # Catch model errors and re-throw as DRF errors | ||||
|                     raise ValidationError(detail=serializers.as_serializer_error(exc)) | ||||
|         # Returns a list of the created items | ||||
|         return items | ||||
|  | ||||
|  | ||||
| @register_importer() | ||||
|   | ||||
| @@ -28,7 +28,7 @@ from order.status_codes import ( | ||||
|     SalesOrderStatusGroups, | ||||
| ) | ||||
| from part.models import Part | ||||
| from stock.models import StockItem | ||||
| from stock.models import StockItem, StockLocation | ||||
| from stock.status_codes import StockStatus | ||||
| from users.models import Owner | ||||
|  | ||||
| @@ -1208,6 +1208,60 @@ class PurchaseOrderReceiveTest(OrderTest): | ||||
|         self.assertEqual(item.quantity, 10) | ||||
|         self.assertEqual(item.batch, 'B-xyz-789') | ||||
|  | ||||
|     def test_receive_large_quantity(self): | ||||
|         """Test receipt of a large number of items.""" | ||||
|         sp = SupplierPart.objects.first() | ||||
|  | ||||
|         # Create a new order | ||||
|         po = models.PurchaseOrder.objects.create( | ||||
|             reference='PO-9999', supplier=sp.supplier | ||||
|         ) | ||||
|  | ||||
|         N_LINES = 250 | ||||
|  | ||||
|         # Create some line items | ||||
|         models.PurchaseOrderLineItem.objects.bulk_create([ | ||||
|             models.PurchaseOrderLineItem(order=po, part=sp, quantity=1000 + i) | ||||
|             for i in range(N_LINES) | ||||
|         ]) | ||||
|  | ||||
|         # Place the order | ||||
|         po.place_order() | ||||
|  | ||||
|         url = reverse('api-po-receive', kwargs={'pk': po.pk}) | ||||
|  | ||||
|         lines = po.lines.all() | ||||
|         location = StockLocation.objects.filter(structural=False).first() | ||||
|  | ||||
|         N_ITEMS = StockItem.objects.count() | ||||
|  | ||||
|         # Receive all items in a single request | ||||
|         response = self.post( | ||||
|             url, | ||||
|             { | ||||
|                 'items': [ | ||||
|                     {'line_item': line.pk, 'quantity': line.quantity} for line in lines | ||||
|                 ], | ||||
|                 'location': location.pk, | ||||
|             }, | ||||
|             max_query_count=100 + 2 * N_LINES, | ||||
|         ).data | ||||
|  | ||||
|         # Check for expected response | ||||
|         self.assertEqual(len(response), N_LINES) | ||||
|         self.assertEqual(N_ITEMS + N_LINES, StockItem.objects.count()) | ||||
|  | ||||
|         for item in response: | ||||
|             self.assertEqual(item['purchase_order'], po.pk) | ||||
|  | ||||
|         # Check that the order has been completed | ||||
|         po.refresh_from_db() | ||||
|         self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE) | ||||
|  | ||||
|         for line in lines: | ||||
|             line.refresh_from_db() | ||||
|             self.assertEqual(line.received, line.quantity) | ||||
|  | ||||
|     def test_packaging(self): | ||||
|         """Test that we can supply a 'packaging' value when receiving items.""" | ||||
|         line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) | ||||
|   | ||||
| @@ -258,6 +258,7 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase): | ||||
|  | ||||
|         order.receive_line_item(line, loc, 50, user=None) | ||||
|  | ||||
|         line.refresh_from_db() | ||||
|         self.assertEqual(line.remaining(), 50) | ||||
|  | ||||
|         self.assertEqual(part.on_order, 1350) | ||||
| @@ -348,6 +349,9 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase): | ||||
|         # Receive 5x item against line_2 | ||||
|         po.receive_line_item(line_2, loc, 5, user=None) | ||||
|  | ||||
|         line_1.refresh_from_db() | ||||
|         line_2.refresh_from_db() | ||||
|  | ||||
|         # Check that the line items have been updated correctly | ||||
|         self.assertEqual(line_1.quantity, 3) | ||||
|         self.assertEqual(line_1.received, 1) | ||||
|   | ||||
| @@ -2059,7 +2059,9 @@ class Part( | ||||
|  | ||||
|         return pricing | ||||
|  | ||||
|     def schedule_pricing_update(self, create: bool = False, force: bool = False): | ||||
|     def schedule_pricing_update( | ||||
|         self, create: bool = False, force: bool = False, refresh: bool = True | ||||
|     ): | ||||
|         """Helper function to schedule a pricing update. | ||||
|  | ||||
|         Importantly, catches any errors which may occur during deletion of related objects, | ||||
| @@ -2070,22 +2072,24 @@ class Part( | ||||
|         Arguments: | ||||
|             create: Whether or not a new PartPricing object should be created if it does not already exist | ||||
|             force: If True, force the pricing to be updated even auto pricing is disabled | ||||
|             refresh: If True, refresh the PartPricing object from the database | ||||
|         """ | ||||
|         if not force and not get_global_setting( | ||||
|             'PRICING_AUTO_UPDATE', backup_value=True | ||||
|         ): | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             self.refresh_from_db() | ||||
|         except Part.DoesNotExist: | ||||
|             return | ||||
|         if refresh: | ||||
|             try: | ||||
|                 self.refresh_from_db() | ||||
|             except Part.DoesNotExist: | ||||
|                 return | ||||
|  | ||||
|         try: | ||||
|             pricing = self.pricing | ||||
|  | ||||
|             if create or pricing.pk: | ||||
|                 pricing.schedule_for_update() | ||||
|                 pricing.schedule_for_update(refresh=refresh) | ||||
|         except IntegrityError: | ||||
|             # If this part instance has been deleted, | ||||
|             # some post-delete or post-save signals may still be fired | ||||
| @@ -2731,11 +2735,12 @@ class PartPricing(common.models.MetaMixin): | ||||
|  | ||||
|         return result | ||||
|  | ||||
|     def schedule_for_update(self, counter: int = 0): | ||||
|     def schedule_for_update(self, counter: int = 0, refresh: bool = True): | ||||
|         """Schedule this pricing to be updated. | ||||
|  | ||||
|         Arguments: | ||||
|             counter: Recursion counter (used to prevent infinite recursion) | ||||
|             refresh: If specified, the PartPricing object will be refreshed from the database | ||||
|         """ | ||||
|         import InvenTree.ready | ||||
|  | ||||
| @@ -2758,7 +2763,7 @@ class PartPricing(common.models.MetaMixin): | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             if self.pk: | ||||
|             if refresh and self.pk: | ||||
|                 self.refresh_from_db() | ||||
|         except (PartPricing.DoesNotExist, IntegrityError): | ||||
|             # Error thrown if this PartPricing instance has already been removed | ||||
| @@ -2770,7 +2775,8 @@ class PartPricing(common.models.MetaMixin): | ||||
|         # Ensure that the referenced part still exists in the database | ||||
|         try: | ||||
|             p = self.part | ||||
|             p.refresh_from_db() | ||||
|             if True:  # refresh and p.pk: | ||||
|                 p.refresh_from_db() | ||||
|         except IntegrityError: | ||||
|             logger.exception( | ||||
|                 "Could not update PartPricing as Part '%s' does not exist", self.part | ||||
|   | ||||
| @@ -275,6 +275,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): | ||||
|  | ||||
|     def test_api_call(self): | ||||
|         """Test that api calls work.""" | ||||
|         import time | ||||
|  | ||||
|         # api_call | ||||
|         result = self.mixin.get_external_url() | ||||
|         self.assertTrue(result) | ||||
| @@ -290,13 +292,22 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): | ||||
|         # Set API TOKEN | ||||
|         self.mixin.set_setting('API_TOKEN', 'reqres-free-v1') | ||||
|         # api_call with post and data | ||||
|         result = self.mixin.api_call( | ||||
|             'https://reqres.in/api/users/', | ||||
|             json={'name': 'morpheus', 'job': 'leader'}, | ||||
|             method='POST', | ||||
|             endpoint_is_url=True, | ||||
|             timeout=5000, | ||||
|         ) | ||||
|  | ||||
|         # Try multiple times, account for the rate limit | ||||
|         result = None | ||||
|  | ||||
|         for _ in range(5): | ||||
|             try: | ||||
|                 result = self.mixin.api_call( | ||||
|                     'https://reqres.in/api/users/', | ||||
|                     json={'name': 'morpheus', 'job': 'leader'}, | ||||
|                     method='POST', | ||||
|                     endpoint_is_url=True, | ||||
|                     timeout=5000, | ||||
|                 ) | ||||
|                 break | ||||
|             except Exception: | ||||
|                 time.sleep(1) | ||||
|  | ||||
|         self.assertTrue(result) | ||||
|         self.assertNotIn('error', result) | ||||
|   | ||||
| @@ -297,17 +297,25 @@ class PluginsRegistry: | ||||
|             active (bool, optional): Filter by 'active' status of plugin. Defaults to True. | ||||
|             builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None. | ||||
|         """ | ||||
|         try: | ||||
|             # Pre-fetch the PluginConfig objects to avoid multiple database queries | ||||
|             from plugin.models import PluginConfig | ||||
|         # We can store the PluginConfig objects against the session cache, | ||||
|         # which allows us to avoid hitting the database multiple times (per session) | ||||
|         # As we have already checked the registry hash, this is a valid cache key | ||||
|         cache_key = f'plugin_configs:{self.registry_hash}' | ||||
|  | ||||
|             plugin_configs = PluginConfig.objects.all() | ||||
|         configs = InvenTree.cache.get_session_cache(cache_key) | ||||
|  | ||||
|             configs = {config.key: config for config in plugin_configs} | ||||
|         except (ProgrammingError, OperationalError): | ||||
|             # The database is not ready yet | ||||
|             logger.warning('plugin.registry.with_mixin: Database not ready') | ||||
|             return [] | ||||
|         if not configs: | ||||
|             try: | ||||
|                 # Pre-fetch the PluginConfig objects to avoid multiple database queries | ||||
|                 from plugin.models import PluginConfig | ||||
|  | ||||
|                 plugin_configs = PluginConfig.objects.all() | ||||
|                 configs = {config.key: config for config in plugin_configs} | ||||
|                 InvenTree.cache.set_session_cache(cache_key, configs) | ||||
|             except (ProgrammingError, OperationalError): | ||||
|                 # The database is not ready yet | ||||
|                 logger.warning('plugin.registry.with_mixin: Database not ready') | ||||
|                 return [] | ||||
|  | ||||
|         mixin = str(mixin).lower().strip() | ||||
|  | ||||
|   | ||||
| @@ -10,12 +10,23 @@ class SampleApiCallerPluginTests(TestCase): | ||||
|  | ||||
|     def test_return(self): | ||||
|         """Check if the external api call works.""" | ||||
|         import time | ||||
|  | ||||
|         # The plugin should be defined | ||||
|         self.assertIn('sample-api-caller', registry.plugins) | ||||
|         plg = registry.plugins['sample-api-caller'] | ||||
|         self.assertTrue(plg) | ||||
|  | ||||
|         # do an api call | ||||
|         result = plg.get_external_url() | ||||
|         # Note: rate limits may apply in CI | ||||
|         result = False | ||||
|  | ||||
|         for _i in range(5): | ||||
|             result = plg.get_external_url() | ||||
|             if result: | ||||
|                 break | ||||
|             else: | ||||
|                 time.sleep(1) | ||||
|  | ||||
|         self.assertTrue(result) | ||||
|         self.assertIn('data', result) | ||||
|   | ||||
| @@ -627,7 +627,11 @@ class StockItem( | ||||
|  | ||||
|         for serial in serials: | ||||
|             data['serial'] = serial | ||||
|             data['serial_int'] = StockItem.convert_serial_to_int(serial) | ||||
|  | ||||
|             if serial is not None: | ||||
|                 data['serial_int'] = StockItem.convert_serial_to_int(serial) or 0 | ||||
|             else: | ||||
|                 data['serial_int'] = 0 | ||||
|  | ||||
|             data['tree_id'] = tree_id | ||||
|  | ||||
| @@ -704,6 +708,10 @@ class StockItem( | ||||
|         """ | ||||
|         serial = str(getattr(self, 'serial', '')).strip() | ||||
|  | ||||
|         if not serial: | ||||
|             self.serial_int = 0 | ||||
|             return | ||||
|  | ||||
|         serial_int = self.convert_serial_to_int(serial) | ||||
|  | ||||
|         try: | ||||
|   | ||||
| @@ -364,7 +364,6 @@ export function RelatedModelField({ | ||||
|         options={data} | ||||
|         filterOption={null} | ||||
|         onInputChange={(value: any) => { | ||||
|           console.log('onInputChange', value); | ||||
|           setValue(value); | ||||
|         }} | ||||
|         onChange={onChange} | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { t } from '@lingui/core/macro'; | ||||
| import { Text } from '@mantine/core'; | ||||
| import type { ReactNode } from 'react'; | ||||
|  | ||||
| import { ModelType } from '@lib/enums/ModelType'; | ||||
| @@ -118,7 +119,7 @@ export function RenderSalesOrderShipment({ | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={order.reference} | ||||
|       secondary={`${t`Shipment`} ${instance.reference}`} | ||||
|       suffix={<Text size='sm'>{`${t`Shipment`} ${instance.reference}`}</Text>} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -63,7 +63,7 @@ export function RenderPartCategory( | ||||
|   const suffix: ReactNode = ( | ||||
|     <Group gap='xs'> | ||||
|       <TableHoverCard | ||||
|         value='' | ||||
|         value={<Text size='sm'>{instance.description}</Text>} | ||||
|         position='bottom-end' | ||||
|         zIndex={10000} | ||||
|         icon='sitemap' | ||||
| @@ -89,7 +89,6 @@ export function RenderPartCategory( | ||||
|         </> | ||||
|       } | ||||
|       primary={category} | ||||
|       secondary={instance.description} | ||||
|       suffix={suffix} | ||||
|       url={ | ||||
|         props.link | ||||
|   | ||||
| @@ -25,7 +25,7 @@ export function RenderStockLocation( | ||||
|   const suffix: ReactNode = ( | ||||
|     <Group gap='xs'> | ||||
|       <TableHoverCard | ||||
|         value='' | ||||
|         value={<Text size='sm'>{instance.description}</Text>} | ||||
|         position='bottom-end' | ||||
|         zIndex={10000} | ||||
|         icon='sitemap' | ||||
| @@ -51,7 +51,6 @@ export function RenderStockLocation( | ||||
|         </> | ||||
|       } | ||||
|       primary={location} | ||||
|       secondary={instance.description} | ||||
|       suffix={suffix} | ||||
|       url={ | ||||
|         props.link | ||||
|   | ||||
| @@ -843,13 +843,6 @@ export default function PartDetail() { | ||||
|           <Skeleton /> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         name: 'builds', | ||||
|         label: t`Build Orders`, | ||||
|         icon: <IconTools />, | ||||
|         hidden: !part.assembly || !user.hasViewRole(UserRoles.build), | ||||
|         content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'used_in', | ||||
|         label: t`Used In`, | ||||
| @@ -905,6 +898,13 @@ export default function PartDetail() { | ||||
|           !globalSettings.isSet('RETURNORDER_ENABLED'), | ||||
|         content: part.pk ? <ReturnOrderTable partId={part.pk} /> : <Skeleton /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'builds', | ||||
|         label: t`Build Orders`, | ||||
|         icon: <IconTools />, | ||||
|         hidden: !part.assembly || !user.hasViewRole(UserRoles.build), | ||||
|         content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'stocktake', | ||||
|         label: t`Stock History`, | ||||
|   | ||||
| @@ -247,38 +247,42 @@ export default function InvenTreeTableHeader({ | ||||
|                 variant='transparent' | ||||
|                 aria-label='table-select-filters' | ||||
|               > | ||||
|                 <Tooltip label={t`Table Filters`} position='top-end'> | ||||
|                   <HoverCard | ||||
|                     position='bottom-end' | ||||
|                     withinPortal={true} | ||||
|                     disabled={!tableState.filterSet.activeFilters?.length} | ||||
|                   > | ||||
|                     <HoverCard.Target> | ||||
|                 <HoverCard | ||||
|                   position='bottom-end' | ||||
|                   withinPortal={true} | ||||
|                   disabled={!tableState.filterSet.activeFilters?.length} | ||||
|                 > | ||||
|                   <HoverCard.Target> | ||||
|                     <Tooltip | ||||
|                       label={t`Table Filters`} | ||||
|                       position='top-end' | ||||
|                       disabled={!!tableState.filterSet.activeFilters?.length} | ||||
|                     > | ||||
|                       <IconFilter | ||||
|                         onClick={() => setFiltersVisible(!filtersVisible)} | ||||
|                       /> | ||||
|                     </HoverCard.Target> | ||||
|                     <HoverCard.Dropdown> | ||||
|                       <Paper p='sm' withBorder> | ||||
|                         <Stack gap='xs'> | ||||
|                           <StylishText size='md'>{t`Active Filters`}</StylishText> | ||||
|                           <Divider /> | ||||
|                           {tableState.filterSet.activeFilters?.map((filter) => ( | ||||
|                             <Group | ||||
|                               key={filter.name} | ||||
|                               justify='space-between' | ||||
|                               gap='xl' | ||||
|                               wrap='nowrap' | ||||
|                             > | ||||
|                               <Text size='sm'>{filter.label}</Text> | ||||
|                               <Text size='xs'>{filter.displayValue}</Text> | ||||
|                             </Group> | ||||
|                           ))} | ||||
|                         </Stack> | ||||
|                       </Paper> | ||||
|                     </HoverCard.Dropdown> | ||||
|                   </HoverCard> | ||||
|                 </Tooltip> | ||||
|                     </Tooltip> | ||||
|                   </HoverCard.Target> | ||||
|                   <HoverCard.Dropdown> | ||||
|                     <Paper p='sm' withBorder> | ||||
|                       <Stack gap='xs'> | ||||
|                         <StylishText size='md'>{t`Active Filters`}</StylishText> | ||||
|                         <Divider /> | ||||
|                         {tableState.filterSet.activeFilters?.map((filter) => ( | ||||
|                           <Group | ||||
|                             key={filter.name} | ||||
|                             justify='space-between' | ||||
|                             gap='xl' | ||||
|                             wrap='nowrap' | ||||
|                           > | ||||
|                             <Text size='sm'>{filter.label}</Text> | ||||
|                             <Text size='xs'>{filter.displayValue}</Text> | ||||
|                           </Group> | ||||
|                         ))} | ||||
|                       </Stack> | ||||
|                     </Paper> | ||||
|                   </HoverCard.Dropdown> | ||||
|                 </HoverCard> | ||||
|               </ActionIcon> | ||||
|             </Indicator> | ||||
|           )} | ||||
|   | ||||
| @@ -616,11 +616,7 @@ test('Parts - Bulk Edit', async ({ browser }) => { | ||||
|   await page.getByLabel('action-menu-part-actions').click(); | ||||
|   await page.getByLabel('action-menu-part-actions-set-category').click(); | ||||
|   await page.getByLabel('related-field-category').fill('rnitu'); | ||||
|   await page | ||||
|     .getByRole('option', { name: '- Furniture/Chairs' }) | ||||
|     .getByRole('paragraph') | ||||
|     .click(); | ||||
|  | ||||
|   await page.getByRole('option', { name: '- Furniture/Chairs' }).click; | ||||
|   await page.getByRole('button', { name: 'Update' }).click(); | ||||
|   await page.getByText('Items Updated').waitFor(); | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user