From 0bed5cf5112f57cc7d53e622c4596ac6eb8d0696 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 15 Aug 2025 22:01:19 +1000 Subject: [PATCH] [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 --- .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/InvenTree/models.py | 9 +- src/backend/InvenTree/InvenTree/unit_test.py | 21 +- src/backend/InvenTree/build/models.py | 6 +- src/backend/InvenTree/order/api.py | 15 +- src/backend/InvenTree/order/models.py | 458 +++++++++++------- src/backend/InvenTree/order/serializers.py | 67 +-- src/backend/InvenTree/order/test_api.py | 56 ++- src/backend/InvenTree/order/tests.py | 4 + src/backend/InvenTree/part/models.py | 24 +- .../plugin/base/integration/test_mixins.py | 25 +- src/backend/InvenTree/plugin/registry.py | 26 +- .../samples/integration/test_api_caller.py | 13 +- src/backend/InvenTree/stock/models.py | 10 +- .../forms/fields/RelatedModelField.tsx | 1 - src/frontend/src/components/render/Order.tsx | 3 +- src/frontend/src/components/render/Part.tsx | 3 +- src/frontend/src/components/render/Stock.tsx | 3 +- src/frontend/src/pages/part/PartDetail.tsx | 14 +- .../src/tables/InvenTreeTableHeader.tsx | 62 +-- src/frontend/tests/pages/pui_part.spec.ts | 6 +- 21 files changed, 519 insertions(+), 313 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 9bcfb33418..19b161bf37 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 2f1341563a..78f0f9774c 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index d87eac3133..9393d10311 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -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: diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index fbf4d96e65..40c0d6bb2c 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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() diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 6361f11adb..7d6a68ea06 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -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.""" diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index cb360198d7..4a6cb4e6c9 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -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. diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 01a1224f09..511b843b99 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -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() diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 0db0ccb697..d9d35090ef 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -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) diff --git a/src/backend/InvenTree/order/tests.py b/src/backend/InvenTree/order/tests.py index adfb2eebf4..b055cf7e84 100644 --- a/src/backend/InvenTree/order/tests.py +++ b/src/backend/InvenTree/order/tests.py @@ -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) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index ade8f06e79..496cd24ef6 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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 diff --git a/src/backend/InvenTree/plugin/base/integration/test_mixins.py b/src/backend/InvenTree/plugin/base/integration/test_mixins.py index fbc6ce027a..ed20b8bd6b 100644 --- a/src/backend/InvenTree/plugin/base/integration/test_mixins.py +++ b/src/backend/InvenTree/plugin/base/integration/test_mixins.py @@ -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) diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index ffbe044224..08bf5075cd 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -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() diff --git a/src/backend/InvenTree/plugin/samples/integration/test_api_caller.py b/src/backend/InvenTree/plugin/samples/integration/test_api_caller.py index ff07af0bca..89132be43f 100644 --- a/src/backend/InvenTree/plugin/samples/integration/test_api_caller.py +++ b/src/backend/InvenTree/plugin/samples/integration/test_api_caller.py @@ -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) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 8857aa571d..724251053a 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -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: diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index 3c1744faba..2164ac4540 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -364,7 +364,6 @@ export function RelatedModelField({ options={data} filterOption={null} onInputChange={(value: any) => { - console.log('onInputChange', value); setValue(value); }} onChange={onChange} diff --git a/src/frontend/src/components/render/Order.tsx b/src/frontend/src/components/render/Order.tsx index 6c41b218f9..36d8397b23 100644 --- a/src/frontend/src/components/render/Order.tsx +++ b/src/frontend/src/components/render/Order.tsx @@ -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 ( {`${t`Shipment`} ${instance.reference}`}} /> ); } diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx index 61e77e6cd1..a76c45502a 100644 --- a/src/frontend/src/components/render/Part.tsx +++ b/src/frontend/src/components/render/Part.tsx @@ -63,7 +63,7 @@ export function RenderPartCategory( const suffix: ReactNode = ( {instance.description}} position='bottom-end' zIndex={10000} icon='sitemap' @@ -89,7 +89,6 @@ export function RenderPartCategory( } primary={category} - secondary={instance.description} suffix={suffix} url={ props.link diff --git a/src/frontend/src/components/render/Stock.tsx b/src/frontend/src/components/render/Stock.tsx index fd6048abca..776dd1b7f0 100644 --- a/src/frontend/src/components/render/Stock.tsx +++ b/src/frontend/src/components/render/Stock.tsx @@ -25,7 +25,7 @@ export function RenderStockLocation( const suffix: ReactNode = ( {instance.description}} position='bottom-end' zIndex={10000} icon='sitemap' @@ -51,7 +51,6 @@ export function RenderStockLocation( } primary={location} - secondary={instance.description} suffix={suffix} url={ props.link diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 965367702e..70d048dcd0 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -843,13 +843,6 @@ export default function PartDetail() { ) }, - { - name: 'builds', - label: t`Build Orders`, - icon: , - hidden: !part.assembly || !user.hasViewRole(UserRoles.build), - content: part.pk ? : - }, { name: 'used_in', label: t`Used In`, @@ -905,6 +898,13 @@ export default function PartDetail() { !globalSettings.isSet('RETURNORDER_ENABLED'), content: part.pk ? : }, + { + name: 'builds', + label: t`Build Orders`, + icon: , + hidden: !part.assembly || !user.hasViewRole(UserRoles.build), + content: part.pk ? : + }, { name: 'stocktake', label: t`Stock History`, diff --git a/src/frontend/src/tables/InvenTreeTableHeader.tsx b/src/frontend/src/tables/InvenTreeTableHeader.tsx index fcf2a5a093..d023dec887 100644 --- a/src/frontend/src/tables/InvenTreeTableHeader.tsx +++ b/src/frontend/src/tables/InvenTreeTableHeader.tsx @@ -247,38 +247,42 @@ export default function InvenTreeTableHeader({ variant='transparent' aria-label='table-select-filters' > - - - + + + setFiltersVisible(!filtersVisible)} /> - - - - - {t`Active Filters`} - - {tableState.filterSet.activeFilters?.map((filter) => ( - - {filter.label} - {filter.displayValue} - - ))} - - - - - + + + + + + {t`Active Filters`} + + {tableState.filterSet.activeFilters?.map((filter) => ( + + {filter.label} + {filter.displayValue} + + ))} + + + + )} diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 8b347961e8..e40da22389 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -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(); });