mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-01 17:51:23 +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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user