mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-01 17:51:23 +00:00
[Feature] SalesOrder Auto-Allocate (#12000)
* Add basic auto-allocate functionality - backend code - background task - API endpoint * Add new endpoint enum * add frontend components * Tweak auto-allocate output * Allow specifying of individual line items * Tweak error boundary * Enable bulk-delete of allocated items against sales order * Refactor stock sorting options * Allow user to select how to handle serialized stock * Backport new functionality to BuildOrder allocation * Refactor sorting options to use enumerated values * Implement functional unit tests for new feature * Update API and CHANGELOG * Additional unit test * Add playwright testing * Documentation * Update docs for build auto-allocate * Fix dependencies * Adjust build line filtering * Fix serializer
This commit is contained in:
@@ -1317,6 +1317,18 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
)
|
||||
|
||||
|
||||
STOCK_SORT_CHOICES = stock.models.STOCK_SORT_CHOICES
|
||||
STOCK_SORT_DEFAULT = stock.models.STOCK_SORT_DEFAULT
|
||||
|
||||
SERIALIZED_STOCK_CHOICES = [
|
||||
('any', _('Allow any stock (serialized or unserialized)')),
|
||||
('serialized', _('Serialized stock only')),
|
||||
('unserialized', _('Unserialized stock only')),
|
||||
]
|
||||
|
||||
SERIALIZED_STOCK_DEFAULT = 'any'
|
||||
|
||||
|
||||
class SalesOrder(TotalPriceMixin, Order):
|
||||
"""A SalesOrder represents a list of goods shipped outwards to a customer."""
|
||||
|
||||
@@ -1469,6 +1481,130 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
"""Return true if any lines in the order are over-allocated."""
|
||||
return any(line.is_overallocated() for line in self.lines.all())
|
||||
|
||||
@transaction.atomic
|
||||
def auto_allocate_stock(
|
||||
self,
|
||||
location: Optional[stock.models.StockLocation] = None,
|
||||
exclude_location: Optional[stock.models.StockLocation] = None,
|
||||
shipment: Optional['SalesOrderShipment'] = None,
|
||||
line_ids: Optional[list] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Automatically allocate stock items against this SalesOrder.
|
||||
|
||||
For each unallocated line item, finds available stock for
|
||||
the line's part, filtered and sorted according to the supplied kwargs, then
|
||||
creates SalesOrderAllocation records in bulk.
|
||||
|
||||
Arguments:
|
||||
location: If provided, only consider stock within this location tree.
|
||||
exclude_location: If provided, exclude stock within this location tree.
|
||||
shipment: Optional shipment to assign allocations to.
|
||||
line_ids: If provided, only allocate against these specific line item PKs.
|
||||
|
||||
Kwargs:
|
||||
interchangeable (bool): If True (default), consume stock from multiple
|
||||
items/locations to satisfy a line. If False, only allocate when a
|
||||
single item can cover the full remaining quantity.
|
||||
"""
|
||||
stock_sort_by = kwargs.get('stock_sort_by', STOCK_SORT_DEFAULT)
|
||||
interchangeable = kwargs.get('interchangeable', True)
|
||||
serialized_stock = kwargs.get('serialized_stock', SERIALIZED_STOCK_DEFAULT)
|
||||
|
||||
new_allocations = []
|
||||
|
||||
lines = self.lines.all()
|
||||
if line_ids:
|
||||
lines = lines.filter(pk__in=line_ids)
|
||||
|
||||
for line_item in lines:
|
||||
if not line_item.part:
|
||||
continue
|
||||
|
||||
if line_item.part.virtual:
|
||||
continue
|
||||
|
||||
unallocated = line_item.quantity - line_item.allocated_quantity()
|
||||
|
||||
if unallocated <= 0:
|
||||
continue
|
||||
|
||||
available_stock = stock.models.StockItem.objects.filter(
|
||||
stock.models.StockItem.IN_STOCK_FILTER, part=line_item.part
|
||||
)
|
||||
|
||||
if location:
|
||||
sublocations = location.get_descendants(include_self=True)
|
||||
available_stock = available_stock.filter(
|
||||
location__in=list(sublocations)
|
||||
)
|
||||
|
||||
if exclude_location:
|
||||
sublocations = exclude_location.get_descendants(include_self=True)
|
||||
available_stock = available_stock.exclude(
|
||||
location__in=list(sublocations)
|
||||
)
|
||||
|
||||
if serialized_stock == 'serialized':
|
||||
available_stock = available_stock.filter(
|
||||
serial__isnull=False, quantity=1
|
||||
).exclude(serial='')
|
||||
elif serialized_stock == 'unserialized':
|
||||
available_stock = available_stock.filter(
|
||||
Q(serial__isnull=True) | Q(serial='')
|
||||
)
|
||||
|
||||
# Handle NULL expiry_date last when sorting by expiry.
|
||||
if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST:
|
||||
available_stock = available_stock.order_by(
|
||||
F('expiry_date').asc(nulls_last=True)
|
||||
)
|
||||
else:
|
||||
available_stock = available_stock.order_by(stock_sort_by)
|
||||
|
||||
stock_count = available_stock.count()
|
||||
|
||||
if stock_count == 0:
|
||||
continue
|
||||
|
||||
if not interchangeable and stock_count > 1:
|
||||
# Only allocate when a single item can fully cover the requirement.
|
||||
single = next(
|
||||
(
|
||||
s
|
||||
for s in available_stock
|
||||
if s.unallocated_quantity() >= unallocated
|
||||
),
|
||||
None,
|
||||
)
|
||||
if single is None:
|
||||
continue
|
||||
available_stock = [single]
|
||||
|
||||
for stock_item in available_stock:
|
||||
available_qty = stock_item.unallocated_quantity()
|
||||
|
||||
if available_qty <= 0:
|
||||
continue
|
||||
|
||||
quantity = min(unallocated, available_qty)
|
||||
|
||||
new_allocations.append(
|
||||
SalesOrderAllocation(
|
||||
line=line_item,
|
||||
item=stock_item,
|
||||
quantity=quantity,
|
||||
shipment=shipment,
|
||||
)
|
||||
)
|
||||
|
||||
unallocated -= quantity
|
||||
|
||||
if unallocated <= 0:
|
||||
break
|
||||
|
||||
SalesOrderAllocation.objects.bulk_create(new_allocations, batch_size=250)
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
"""Check if this order is "shipped" (all line items delivered).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user