mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-20 22:00:27 -06: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 information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v384 -> 2025-08-08 : https://github.com/inventree/InvenTree/pull/9969
|
||||||
- Bump allauth
|
- Bump allauth
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from string import Formatter
|
from string import Formatter
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -1154,12 +1155,16 @@ class InvenTreeBarcodeMixin(models.Model):
|
|||||||
return self.format_barcode()
|
return self.format_barcode()
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""Check if a model instance exists with the specified third-party barcode hash."""
|
||||||
return cls.objects.filter(barcode_hash=barcode_hash).first()
|
return cls.objects.filter(barcode_hash=barcode_hash).first()
|
||||||
|
|
||||||
def assign_barcode(
|
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."""
|
"""Assign an external (third-party) barcode to this object."""
|
||||||
# Must provide either barcode_hash or barcode_data
|
# Must provide either barcode_hash or barcode_data
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ from plugin.models import PluginConfig
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def count_queries(
|
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
|
): # pragma: no cover
|
||||||
"""Helper function to count the number of queries executed.
|
"""Helper function to count the number of queries executed.
|
||||||
|
|
||||||
@@ -36,10 +39,15 @@ def count_queries(
|
|||||||
msg: Optional message to print after counting queries
|
msg: Optional message to print after counting queries
|
||||||
log_to_file: If True, log the queries to a file (default = False)
|
log_to_file: If True, log the queries to a file (default = False)
|
||||||
using: The database connection to use (default = 'default')
|
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:
|
with CaptureQueriesContext(connections[using]) as context:
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
dt = time.time() - t1
|
||||||
|
|
||||||
n = len(context.captured_queries)
|
n = len(context.captured_queries)
|
||||||
|
|
||||||
if log_to_file:
|
if log_to_file:
|
||||||
@@ -47,10 +55,13 @@ def count_queries(
|
|||||||
for q in context.captured_queries:
|
for q in context.captured_queries:
|
||||||
f.write(str(q['sql']) + '\n\n')
|
f.write(str(q['sql']) + '\n\n')
|
||||||
|
|
||||||
if msg:
|
output = f'Executed {n} queries in {dt:.4f}s'
|
||||||
print(f'{msg}: Executed {n} queries')
|
|
||||||
else:
|
if threshold and n >= threshold:
|
||||||
print(f'Executed {n} queries')
|
if msg:
|
||||||
|
print(f'{msg}: {output}')
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
|
||||||
def addUserPermission(user: User, app_name: str, model_name: str, perm: str) -> None:
|
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.exceptions import ValidationError
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import F, Q, Sum
|
from django.db.models import F, Q, QuerySet, Sum
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch.dispatcher import receiver
|
from django.dispatch.dispatcher import receiver
|
||||||
@@ -870,7 +870,7 @@ class Build(
|
|||||||
allocations.delete()
|
allocations.delete()
|
||||||
|
|
||||||
@transaction.atomic
|
@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.
|
"""Create a new build output against this BuildOrder.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
@@ -883,7 +883,7 @@ class Build(
|
|||||||
auto_allocate: Automatically allocate stock with matching serial numbers
|
auto_allocate: Automatically allocate stock with matching serial numbers
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of the created output (StockItem) objects.
|
A QuerySet of the created output (StockItem) objects.
|
||||||
"""
|
"""
|
||||||
trackable_parts = self.part.get_trackable_parts()
|
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_filters import rest_framework as rest_filters
|
||||||
from django_ical.views import ICalFeed
|
from django_ical.views import ICalFeed
|
||||||
from drf_spectacular.types import OpenApiTypes
|
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 import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ import common.models
|
|||||||
import common.settings
|
import common.settings
|
||||||
import company.models
|
import company.models
|
||||||
import stock.models as stock_models
|
import stock.models as stock_models
|
||||||
|
import stock.serializers as stock_serializers
|
||||||
from data_exporter.mixins import DataExportViewMixin
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from generic.states.api import StatusView
|
from generic.states.api import StatusView
|
||||||
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
||||||
@@ -472,6 +473,7 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI):
|
|||||||
serializer_class = serializers.PurchaseOrderIssueSerializer
|
serializer_class = serializers.PurchaseOrderIssueSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)})
|
||||||
class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
|
class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
|
||||||
"""API endpoint to receive stock items against a PurchaseOrder.
|
"""API endpoint to receive stock items against a PurchaseOrder.
|
||||||
|
|
||||||
@@ -489,9 +491,18 @@ class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = models.PurchaseOrderLineItem.objects.none()
|
queryset = models.PurchaseOrderLineItem.objects.none()
|
||||||
|
|
||||||
serializer_class = serializers.PurchaseOrderReceiveSerializer
|
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):
|
class PurchaseOrderLineItemFilter(LineItemFilter):
|
||||||
"""Custom filters for the PurchaseOrderLineItemList endpoint."""
|
"""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.exceptions import ValidationError
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import F, Q, Sum
|
from django.db.models import F, Q, QuerySet, Sum
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch.dispatcher import receiver
|
from django.dispatch.dispatcher import receiver
|
||||||
@@ -782,10 +782,16 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
unique_parts = set()
|
||||||
|
|
||||||
# Schedule pricing update for any referenced parts
|
# 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:
|
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)
|
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 True if all line items have been received."""
|
||||||
return self.pending_line_items().count() == 0
|
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
|
@transaction.atomic
|
||||||
def receive_line_item(
|
def receive_line_item(
|
||||||
self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs
|
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
|
notes: Optional notes field for the StockItem
|
||||||
packaging: Optional packaging field for the StockItem
|
packaging: Optional packaging field for the StockItem
|
||||||
barcode: Optional barcode field for the StockItem
|
barcode: Optional barcode field for the StockItem
|
||||||
|
notify: If true, notify users of received items
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the quantity is negative or otherwise invalid
|
ValidationError: If the quantity is negative or otherwise invalid
|
||||||
ValidationError: If the order is not in the 'PLACED' state
|
ValidationError: If the order is not in the 'PLACED' state
|
||||||
"""
|
"""
|
||||||
# Extract optional batch code for the new stock item
|
self.receive_line_items(
|
||||||
batch_code = kwargs.get('batch_code', '')
|
location,
|
||||||
|
[
|
||||||
# Extract optional expiry date for the new stock item
|
{
|
||||||
expiry_date = kwargs.get('expiry_date')
|
'line_item': line,
|
||||||
|
'quantity': quantity,
|
||||||
# Extract optional list of serial numbers
|
'location': location,
|
||||||
serials = kwargs.get('serials')
|
'status': status,
|
||||||
|
**kwargs,
|
||||||
# Extract optional notes field
|
}
|
||||||
notes = kwargs.get('notes', '')
|
],
|
||||||
|
user,
|
||||||
# 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(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1582,8 +1687,11 @@ class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
'reference': _('The order is locked and cannot be modified')
|
'reference': _('The order is locked and cannot be modified')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
update_order = kwargs.pop('update_order', True)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
self.order.save()
|
if update_order and self.order:
|
||||||
|
self.order.save()
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""Custom delete method for the OrderLineItem model.
|
"""Custom delete method for the OrderLineItem model.
|
||||||
|
|||||||
@@ -755,19 +755,11 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
line_item = serializers.PrimaryKeyRelatedField(
|
line_item = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=order.models.PurchaseOrderLineItem.objects.all(),
|
queryset=order.models.PurchaseOrderLineItem.objects.all(),
|
||||||
many=False,
|
|
||||||
allow_null=False,
|
allow_null=False,
|
||||||
required=True,
|
required=True,
|
||||||
label=_('Line Item'),
|
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(
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=stock.models.StockLocation.objects.all(),
|
queryset=stock.models.StockLocation.objects.all(),
|
||||||
many=False,
|
many=False,
|
||||||
@@ -864,19 +856,12 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
|||||||
quantity = data['quantity']
|
quantity = data['quantity']
|
||||||
serial_numbers = data.get('serial_numbers', '').strip()
|
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 are provided
|
||||||
if serial_numbers:
|
if serial_numbers:
|
||||||
|
supplier_part = line_item.part
|
||||||
|
base_part = supplier_part.part
|
||||||
|
base_quantity = supplier_part.base_quantity(quantity)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Pass the serial numbers through to the parent serializer once validated
|
# Pass the serial numbers through to the parent serializer once validated
|
||||||
data['serials'] = extract_serial_numbers(
|
data['serials'] = extract_serial_numbers(
|
||||||
@@ -940,6 +925,9 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
if len(items) == 0:
|
if len(items) == 0:
|
||||||
raise ValidationError(_('Line items must be provided'))
|
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
|
# Check if the location is not specified for any particular item
|
||||||
for item in items:
|
for item in items:
|
||||||
line = item['line_item']
|
line = item['line_item']
|
||||||
@@ -957,10 +945,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
'location': _('Destination location must be specified')
|
'location': _('Destination location must be specified')
|
||||||
})
|
})
|
||||||
|
|
||||||
# Ensure barcodes are unique
|
|
||||||
unique_barcodes = set()
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
barcode = item.get('barcode', '')
|
barcode = item.get('barcode', '')
|
||||||
|
|
||||||
if barcode:
|
if barcode:
|
||||||
@@ -971,7 +955,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self) -> list[stock.models.StockItem]:
|
||||||
"""Perform the actual database transaction to receive purchase order items."""
|
"""Perform the actual database transaction to receive purchase order items."""
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
|
|
||||||
@@ -983,33 +967,16 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
# Location can be provided, or default to the order destination
|
# Location can be provided, or default to the order destination
|
||||||
location = data.get('location', order.destination)
|
location = data.get('location', order.destination)
|
||||||
|
|
||||||
# Now we can actually receive the items into stock
|
try:
|
||||||
with transaction.atomic():
|
items = order.receive_line_items(
|
||||||
for item in items:
|
location, items, request.user if request else None
|
||||||
# Select location (in descending order of priority)
|
)
|
||||||
loc = (
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
item.get('location', None)
|
# Catch model errors and re-throw as DRF errors
|
||||||
or location
|
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||||
or item['line_item'].get_destination()
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
# Returns a list of the created items
|
||||||
order.receive_line_item(
|
return items
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
@register_importer()
|
@register_importer()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from order.status_codes import (
|
|||||||
SalesOrderStatusGroups,
|
SalesOrderStatusGroups,
|
||||||
)
|
)
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem, StockLocation
|
||||||
from stock.status_codes import StockStatus
|
from stock.status_codes import StockStatus
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
|
|
||||||
@@ -1208,6 +1208,60 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(item.quantity, 10)
|
self.assertEqual(item.quantity, 10)
|
||||||
self.assertEqual(item.batch, 'B-xyz-789')
|
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):
|
def test_packaging(self):
|
||||||
"""Test that we can supply a 'packaging' value when receiving items."""
|
"""Test that we can supply a 'packaging' value when receiving items."""
|
||||||
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
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)
|
order.receive_line_item(line, loc, 50, user=None)
|
||||||
|
|
||||||
|
line.refresh_from_db()
|
||||||
self.assertEqual(line.remaining(), 50)
|
self.assertEqual(line.remaining(), 50)
|
||||||
|
|
||||||
self.assertEqual(part.on_order, 1350)
|
self.assertEqual(part.on_order, 1350)
|
||||||
@@ -348,6 +349,9 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
|
|||||||
# Receive 5x item against line_2
|
# Receive 5x item against line_2
|
||||||
po.receive_line_item(line_2, loc, 5, user=None)
|
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
|
# Check that the line items have been updated correctly
|
||||||
self.assertEqual(line_1.quantity, 3)
|
self.assertEqual(line_1.quantity, 3)
|
||||||
self.assertEqual(line_1.received, 1)
|
self.assertEqual(line_1.received, 1)
|
||||||
|
|||||||
@@ -2059,7 +2059,9 @@ class Part(
|
|||||||
|
|
||||||
return pricing
|
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.
|
"""Helper function to schedule a pricing update.
|
||||||
|
|
||||||
Importantly, catches any errors which may occur during deletion of related objects,
|
Importantly, catches any errors which may occur during deletion of related objects,
|
||||||
@@ -2070,22 +2072,24 @@ class Part(
|
|||||||
Arguments:
|
Arguments:
|
||||||
create: Whether or not a new PartPricing object should be created if it does not already exist
|
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
|
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(
|
if not force and not get_global_setting(
|
||||||
'PRICING_AUTO_UPDATE', backup_value=True
|
'PRICING_AUTO_UPDATE', backup_value=True
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
if refresh:
|
||||||
self.refresh_from_db()
|
try:
|
||||||
except Part.DoesNotExist:
|
self.refresh_from_db()
|
||||||
return
|
except Part.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pricing = self.pricing
|
pricing = self.pricing
|
||||||
|
|
||||||
if create or pricing.pk:
|
if create or pricing.pk:
|
||||||
pricing.schedule_for_update()
|
pricing.schedule_for_update(refresh=refresh)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# If this part instance has been deleted,
|
# If this part instance has been deleted,
|
||||||
# some post-delete or post-save signals may still be fired
|
# some post-delete or post-save signals may still be fired
|
||||||
@@ -2731,11 +2735,12 @@ class PartPricing(common.models.MetaMixin):
|
|||||||
|
|
||||||
return result
|
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.
|
"""Schedule this pricing to be updated.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
counter: Recursion counter (used to prevent infinite recursion)
|
counter: Recursion counter (used to prevent infinite recursion)
|
||||||
|
refresh: If specified, the PartPricing object will be refreshed from the database
|
||||||
"""
|
"""
|
||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
|
|
||||||
@@ -2758,7 +2763,7 @@ class PartPricing(common.models.MetaMixin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.pk:
|
if refresh and self.pk:
|
||||||
self.refresh_from_db()
|
self.refresh_from_db()
|
||||||
except (PartPricing.DoesNotExist, IntegrityError):
|
except (PartPricing.DoesNotExist, IntegrityError):
|
||||||
# Error thrown if this PartPricing instance has already been removed
|
# 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
|
# Ensure that the referenced part still exists in the database
|
||||||
try:
|
try:
|
||||||
p = self.part
|
p = self.part
|
||||||
p.refresh_from_db()
|
if True: # refresh and p.pk:
|
||||||
|
p.refresh_from_db()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Could not update PartPricing as Part '%s' does not exist", self.part
|
"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):
|
def test_api_call(self):
|
||||||
"""Test that api calls work."""
|
"""Test that api calls work."""
|
||||||
|
import time
|
||||||
|
|
||||||
# api_call
|
# api_call
|
||||||
result = self.mixin.get_external_url()
|
result = self.mixin.get_external_url()
|
||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
@@ -290,13 +292,22 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
# Set API TOKEN
|
# Set API TOKEN
|
||||||
self.mixin.set_setting('API_TOKEN', 'reqres-free-v1')
|
self.mixin.set_setting('API_TOKEN', 'reqres-free-v1')
|
||||||
# api_call with post and data
|
# api_call with post and data
|
||||||
result = self.mixin.api_call(
|
|
||||||
'https://reqres.in/api/users/',
|
# Try multiple times, account for the rate limit
|
||||||
json={'name': 'morpheus', 'job': 'leader'},
|
result = None
|
||||||
method='POST',
|
|
||||||
endpoint_is_url=True,
|
for _ in range(5):
|
||||||
timeout=5000,
|
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.assertTrue(result)
|
||||||
self.assertNotIn('error', result)
|
self.assertNotIn('error', result)
|
||||||
|
|||||||
@@ -297,17 +297,25 @@ class PluginsRegistry:
|
|||||||
active (bool, optional): Filter by 'active' status of plugin. Defaults to True.
|
active (bool, optional): Filter by 'active' status of plugin. Defaults to True.
|
||||||
builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None.
|
builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None.
|
||||||
"""
|
"""
|
||||||
try:
|
# We can store the PluginConfig objects against the session cache,
|
||||||
# Pre-fetch the PluginConfig objects to avoid multiple database queries
|
# which allows us to avoid hitting the database multiple times (per session)
|
||||||
from plugin.models import PluginConfig
|
# 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}
|
if not configs:
|
||||||
except (ProgrammingError, OperationalError):
|
try:
|
||||||
# The database is not ready yet
|
# Pre-fetch the PluginConfig objects to avoid multiple database queries
|
||||||
logger.warning('plugin.registry.with_mixin: Database not ready')
|
from plugin.models import PluginConfig
|
||||||
return []
|
|
||||||
|
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()
|
mixin = str(mixin).lower().strip()
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,23 @@ class SampleApiCallerPluginTests(TestCase):
|
|||||||
|
|
||||||
def test_return(self):
|
def test_return(self):
|
||||||
"""Check if the external api call works."""
|
"""Check if the external api call works."""
|
||||||
|
import time
|
||||||
|
|
||||||
# The plugin should be defined
|
# The plugin should be defined
|
||||||
self.assertIn('sample-api-caller', registry.plugins)
|
self.assertIn('sample-api-caller', registry.plugins)
|
||||||
plg = registry.plugins['sample-api-caller']
|
plg = registry.plugins['sample-api-caller']
|
||||||
self.assertTrue(plg)
|
self.assertTrue(plg)
|
||||||
|
|
||||||
# do an api call
|
# 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.assertTrue(result)
|
||||||
self.assertIn('data', result)
|
self.assertIn('data', result)
|
||||||
|
|||||||
@@ -627,7 +627,11 @@ class StockItem(
|
|||||||
|
|
||||||
for serial in serials:
|
for serial in serials:
|
||||||
data['serial'] = serial
|
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
|
data['tree_id'] = tree_id
|
||||||
|
|
||||||
@@ -704,6 +708,10 @@ class StockItem(
|
|||||||
"""
|
"""
|
||||||
serial = str(getattr(self, 'serial', '')).strip()
|
serial = str(getattr(self, 'serial', '')).strip()
|
||||||
|
|
||||||
|
if not serial:
|
||||||
|
self.serial_int = 0
|
||||||
|
return
|
||||||
|
|
||||||
serial_int = self.convert_serial_to_int(serial)
|
serial_int = self.convert_serial_to_int(serial)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -364,7 +364,6 @@ export function RelatedModelField({
|
|||||||
options={data}
|
options={data}
|
||||||
filterOption={null}
|
filterOption={null}
|
||||||
onInputChange={(value: any) => {
|
onInputChange={(value: any) => {
|
||||||
console.log('onInputChange', value);
|
|
||||||
setValue(value);
|
setValue(value);
|
||||||
}}
|
}}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { Text } from '@mantine/core';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { ModelType } from '@lib/enums/ModelType';
|
import { ModelType } from '@lib/enums/ModelType';
|
||||||
@@ -118,7 +119,7 @@ export function RenderSalesOrderShipment({
|
|||||||
return (
|
return (
|
||||||
<RenderInlineModel
|
<RenderInlineModel
|
||||||
primary={order.reference}
|
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 = (
|
const suffix: ReactNode = (
|
||||||
<Group gap='xs'>
|
<Group gap='xs'>
|
||||||
<TableHoverCard
|
<TableHoverCard
|
||||||
value=''
|
value={<Text size='sm'>{instance.description}</Text>}
|
||||||
position='bottom-end'
|
position='bottom-end'
|
||||||
zIndex={10000}
|
zIndex={10000}
|
||||||
icon='sitemap'
|
icon='sitemap'
|
||||||
@@ -89,7 +89,6 @@ export function RenderPartCategory(
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
primary={category}
|
primary={category}
|
||||||
secondary={instance.description}
|
|
||||||
suffix={suffix}
|
suffix={suffix}
|
||||||
url={
|
url={
|
||||||
props.link
|
props.link
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function RenderStockLocation(
|
|||||||
const suffix: ReactNode = (
|
const suffix: ReactNode = (
|
||||||
<Group gap='xs'>
|
<Group gap='xs'>
|
||||||
<TableHoverCard
|
<TableHoverCard
|
||||||
value=''
|
value={<Text size='sm'>{instance.description}</Text>}
|
||||||
position='bottom-end'
|
position='bottom-end'
|
||||||
zIndex={10000}
|
zIndex={10000}
|
||||||
icon='sitemap'
|
icon='sitemap'
|
||||||
@@ -51,7 +51,6 @@ export function RenderStockLocation(
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
primary={location}
|
primary={location}
|
||||||
secondary={instance.description}
|
|
||||||
suffix={suffix}
|
suffix={suffix}
|
||||||
url={
|
url={
|
||||||
props.link
|
props.link
|
||||||
|
|||||||
@@ -843,13 +843,6 @@ export default function PartDetail() {
|
|||||||
<Skeleton />
|
<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',
|
name: 'used_in',
|
||||||
label: t`Used In`,
|
label: t`Used In`,
|
||||||
@@ -905,6 +898,13 @@ export default function PartDetail() {
|
|||||||
!globalSettings.isSet('RETURNORDER_ENABLED'),
|
!globalSettings.isSet('RETURNORDER_ENABLED'),
|
||||||
content: part.pk ? <ReturnOrderTable partId={part.pk} /> : <Skeleton />
|
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',
|
name: 'stocktake',
|
||||||
label: t`Stock History`,
|
label: t`Stock History`,
|
||||||
|
|||||||
@@ -247,38 +247,42 @@ export default function InvenTreeTableHeader({
|
|||||||
variant='transparent'
|
variant='transparent'
|
||||||
aria-label='table-select-filters'
|
aria-label='table-select-filters'
|
||||||
>
|
>
|
||||||
<Tooltip label={t`Table Filters`} position='top-end'>
|
<HoverCard
|
||||||
<HoverCard
|
position='bottom-end'
|
||||||
position='bottom-end'
|
withinPortal={true}
|
||||||
withinPortal={true}
|
disabled={!tableState.filterSet.activeFilters?.length}
|
||||||
disabled={!tableState.filterSet.activeFilters?.length}
|
>
|
||||||
>
|
<HoverCard.Target>
|
||||||
<HoverCard.Target>
|
<Tooltip
|
||||||
|
label={t`Table Filters`}
|
||||||
|
position='top-end'
|
||||||
|
disabled={!!tableState.filterSet.activeFilters?.length}
|
||||||
|
>
|
||||||
<IconFilter
|
<IconFilter
|
||||||
onClick={() => setFiltersVisible(!filtersVisible)}
|
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||||
/>
|
/>
|
||||||
</HoverCard.Target>
|
</Tooltip>
|
||||||
<HoverCard.Dropdown>
|
</HoverCard.Target>
|
||||||
<Paper p='sm' withBorder>
|
<HoverCard.Dropdown>
|
||||||
<Stack gap='xs'>
|
<Paper p='sm' withBorder>
|
||||||
<StylishText size='md'>{t`Active Filters`}</StylishText>
|
<Stack gap='xs'>
|
||||||
<Divider />
|
<StylishText size='md'>{t`Active Filters`}</StylishText>
|
||||||
{tableState.filterSet.activeFilters?.map((filter) => (
|
<Divider />
|
||||||
<Group
|
{tableState.filterSet.activeFilters?.map((filter) => (
|
||||||
key={filter.name}
|
<Group
|
||||||
justify='space-between'
|
key={filter.name}
|
||||||
gap='xl'
|
justify='space-between'
|
||||||
wrap='nowrap'
|
gap='xl'
|
||||||
>
|
wrap='nowrap'
|
||||||
<Text size='sm'>{filter.label}</Text>
|
>
|
||||||
<Text size='xs'>{filter.displayValue}</Text>
|
<Text size='sm'>{filter.label}</Text>
|
||||||
</Group>
|
<Text size='xs'>{filter.displayValue}</Text>
|
||||||
))}
|
</Group>
|
||||||
</Stack>
|
))}
|
||||||
</Paper>
|
</Stack>
|
||||||
</HoverCard.Dropdown>
|
</Paper>
|
||||||
</HoverCard>
|
</HoverCard.Dropdown>
|
||||||
</Tooltip>
|
</HoverCard>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Indicator>
|
</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').click();
|
||||||
await page.getByLabel('action-menu-part-actions-set-category').click();
|
await page.getByLabel('action-menu-part-actions-set-category').click();
|
||||||
await page.getByLabel('related-field-category').fill('rnitu');
|
await page.getByLabel('related-field-category').fill('rnitu');
|
||||||
await page
|
await page.getByRole('option', { name: '- Furniture/Chairs' }).click;
|
||||||
.getByRole('option', { name: '- Furniture/Chairs' })
|
|
||||||
.getByRole('paragraph')
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).click();
|
await page.getByRole('button', { name: 'Update' }).click();
|
||||||
await page.getByText('Items Updated').waitFor();
|
await page.getByText('Items Updated').waitFor();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user