Merge commit 'bc03f349085582e99606b94ff9c4cded905aeb1b' into block-notes

This commit is contained in:
Oliver Walters
2026-05-26 15:06:32 +00:00
33 changed files with 1986 additions and 51 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).