mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-30 12:45: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