[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:
Oliver
2026-05-26 23:21:06 +10:00
committed by GitHub
parent 06680758c3
commit 540eb84796
23 changed files with 1468 additions and 22 deletions
+136
View File
@@ -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).