[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:
Oliver
2025-08-15 22:01:19 +10:00
committed by GitHub
parent 0f04c31ffb
commit 0bed5cf511
21 changed files with 519 additions and 313 deletions
+283 -175
View File
@@ -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.