Transfer Order (#11281)

* initial skel commit for transfer orders

* initial transfer order backend model

* add some serializers, rename PLACED to ISSUED for TransferOrders

* adding from admin console works

* simple table list almost working, but we need to add order line items....

* add other cols to table

* add Transfer Order from table view

* moving towards a detail view

* wip: adding detail view

* add take from and destination serializer details

* add other detail grid items

* edit/duplicate transfer order

* more action buttons

* first crack at adding line items

* add to line item

* add filters

* starting work on row actions

* more action buttons for line items

* fix copy lines in duplicate

* basic allocation works

* allocations table actions

* allocate serials

* allocated serial row expansion

* add transferred qty to serializers

* move items on complete, show in tracking

* change panel to transferred stock upon complete

* allow incomplete line items

* disable edit allocations when completed

* add ref pattern and to settings

* add admin to line item inline

* add calendar and parametric view

* basic transfer order report

* add transfer order ruleset

* starting allocation buisness logic throughout for TOs

* disable accept incomplete logic, which was incorrect, until I fix

* fix incomplete allocation option

* add transferred col to default report

* add transfer order to calendar ics view

* chain condition for readability

* add transfer order allocations table to stockitem view

* don't account TO allocations in availability

* add transfer orders table for a part

* 'consume' option by doing take_stock

* squash migrations

* starting to test transfer order

* more transfer order tests

* add transfer order consume test

* wip, more tests

* more transfer order tests

* had to refresh_from_db

* switch "to" to "transfer-order" in url paths

* only select non-virtual parts from transfer order

* add transfer order docs

* deconflict migrations

* fix frontend build error

* fix validation on transfer order reference pattern

* add oath2 scope for transfer order

* fix state test to include transfer order state

* add barcode_model_type_code for transfer order

* bump api version

* check view role for transfer order, remove debug/commented out lines

* add serialized allocation test

* Fix migrations

* Frontend fixes

* Implement required 'company' attribute

* transfer order report context

* attempt to fix tests

* delete transfer order allocations on cancel

* add a few playwright tests, more incoming

* more playwright

* add source and destination locations to table

* deconflict migrations

* Fix build issue

* attempt to fix flaky transfer order test

* duplicate transfer order before running tests

* Adjust playwright tests

* Fix migration dependency order

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Jacob Felknor
2026-05-22 17:08:40 +10:00
committed by GitHub
co-authored by Oliver Matthias Mair
parent 5489656016
commit 74d9ab6d11
53 changed files with 6178 additions and 35 deletions
+683 -12
View File
@@ -45,7 +45,12 @@ from InvenTree.fields import (
)
from InvenTree.helpers import decimal2string, pui_url
from InvenTree.helpers_model import notify_responsible
from order.events import PurchaseOrderEvents, ReturnOrderEvents, SalesOrderEvents
from order.events import (
PurchaseOrderEvents,
ReturnOrderEvents,
SalesOrderEvents,
TransferOrderEvents,
)
from order.status_codes import (
PurchaseOrderStatus,
PurchaseOrderStatusGroups,
@@ -54,6 +59,8 @@ from order.status_codes import (
ReturnOrderStatusGroups,
SalesOrderStatus,
SalesOrderStatusGroups,
TransferOrderStatus,
TransferOrderStatusGroups,
)
from part import models as PartModels
from plugin.events import trigger_event
@@ -265,6 +272,27 @@ class ReturnOrderReportContext(report.mixins.BaseReportContext, TypedDict):
customer: Optional[Company]
class TransferOrderReportContext(report.mixins.BaseReportContext, TypedDict):
"""Context for the transfer order model.
Attributes:
description: The description field of the TransferOrder
reference: The reference field of the TransferOrder
title: The title (string representation) of the TransferOrder
lines: Query set of all line items associated with the TransferOrder
order: The TransferOrder instance itself
"""
description: str
reference: str
title: str
lines: report.mixins.QuerySet['TransferOrderLineItem']
order: 'TransferOrder'
take_from: 'stock.models.StockLocation'
destination: 'stock.models.StockLocation'
consume: bool
class Order(
StatusCodeMixin,
StateTransitionMixin,
@@ -374,11 +402,16 @@ class Order(
})
# Check that the referenced 'contact' matches the correct 'company'
if self.company and self.contact:
if self.contact.company != self.company:
raise ValidationError({
'contact': _('Contact does not match selected company')
})
if (
hasattr(self, 'company')
and hasattr(self, 'contact')
and self.company
and self.contact
and (self.contact.company != self.company)
):
raise ValidationError({
'contact': _('Contact does not match selected company')
})
# Target date should be *after* the start date
if self.start_date and self.target_date and self.start_date > self.target_date:
@@ -388,11 +421,15 @@ class Order(
})
# Check that the referenced 'address' matches the correct 'company'
if self.company and self.address:
if self.address.company != self.company:
raise ValidationError({
'address': _('Address does not match selected company')
})
if (
hasattr(self, 'company')
and self.company
and self.address
and (self.address.company != self.company)
):
raise ValidationError({
'address': _('Address does not match selected company')
})
def clean_line_item(self, line):
"""Clean a line item for this order.
@@ -408,7 +445,9 @@ class Order(
"""Generate context data for the reporting interface."""
return {
'description': self.description,
'extra_lines': self.extra_lines,
'extra_lines': getattr(
self, 'extra_lines', None
), # Transfer Order doesn't have extra lines
'lines': self.lines,
'order': self,
'reference': self.reference,
@@ -3155,6 +3194,628 @@ class ReturnOrderExtraLine(OrderExtraLine):
)
class TransferOrder(Order):
"""A Transfer Order represents a request to transfer stock from one location to another. It provides a place to queue and review changes before execution.
Attributes:
take_from: The stock location to source items from (or null to )
destination: The stock location to move items to
consume: Rather than move the stock, "consume" it. Helpful if you want to queue up removing stock from inventory
"""
# Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE'
STATUS_CLASS = TransferOrderStatus
# UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
class Meta:
"""Model meta options."""
verbose_name = _('Transfer Order')
def report_context(self) -> TransferOrderReportContext:
"""Return report context data for this TransferOrder."""
return {
**super().report_context(),
'take_from': self.take_from,
'destination': self.destination,
'consume': self.consume,
}
def get_absolute_url(self) -> str:
"""Get the 'web' URL for this order."""
return pui_url(f'/stock/transfer-order/{self.pk}')
@staticmethod
def get_api_url() -> str:
"""Return the API URL associated with the TransferOrder model."""
return reverse('api-transfer-order-list')
@classmethod
def get_status_class(cls):
"""Return the TransferOrderStatus class."""
return TransferOrderStatusGroups
@classmethod
def api_defaults(cls, request=None):
"""Return default values for this model when issuing an API OPTIONS request."""
defaults = {
'reference': order.validators.generate_next_transfer_order_reference()
}
return defaults
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'TO'
def subscribed_users(self) -> list[User]:
"""Return a list of users subscribed to this TransferOrder.
By this, we mean users to are interested in any of the parts associated with this order.
"""
subscribed_users = set()
for line in self.lines.all():
if line.part:
# Add the part to the list of subscribed users
for user in line.part.get_subscribers():
subscribed_users.add(user)
return list(subscribed_users)
def clean_line_item(self, line):
"""Clean a line item for this PurchaseOrder."""
super().clean_line_item(line)
line.transferred = 0
def __str__(self):
"""Render a string representation of this TransferOrder."""
return f'{self.reference} - {self.take_from.name if self.take_from else _("deleted")} --> {self.destination.name if self.destination else _("deleted")}'
reference = models.CharField(
unique=True,
max_length=64,
blank=False,
help_text=_('Transfer Order Reference'),
verbose_name=_('Reference'),
default=order.validators.generate_next_transfer_order_reference,
validators=[order.validators.validate_transfer_order_reference],
)
status = InvenTreeCustomStatusModelField(
default=TransferOrderStatus.PENDING.value,
choices=TransferOrderStatus.items(),
status_class=TransferOrderStatus,
verbose_name=_('Status'),
help_text=_('Transfer order status'),
)
@property
def status_text(self):
"""Return the text representation of the status field."""
return TransferOrderStatus.text(self.status)
take_from = models.ForeignKey(
'stock.StockLocation',
verbose_name=_('Source Location'),
on_delete=models.SET_NULL,
related_name='sourcing_transfers',
blank=True,
null=True,
help_text=_('Source for transferred items'),
)
destination = models.ForeignKey(
'stock.StockLocation',
verbose_name=_('Destination Location'),
on_delete=models.SET_NULL,
related_name='incoming_transfers',
blank=True,
null=True,
help_text=_('Destination for transferred items'),
)
consume = models.BooleanField(
default=False,
verbose_name=_('Consume Stock'),
help_text=_(
'Rather than transfer the stock to the destination, "consume" it, by removing transferred quantity from the allocated stock item'
),
)
complete_date = models.DateField(
blank=True,
null=True,
verbose_name=_('Completion Date'),
help_text=_('Date order was completed'),
)
@property
def company(self) -> None:
"""Required accessor helper for Order base class."""
return None
@property
def is_pending(self) -> bool:
"""Return True if the TransferOrder is 'pending'."""
return self.status == TransferOrderStatus.PENDING.value
@property
def is_open(self) -> bool:
"""Return True if the TransferOrder is 'open'."""
return self.status in TransferOrderStatusGroups.OPEN
@property
def stock_allocations(self) -> QuerySet:
"""Return a queryset containing all allocations for this order."""
return TransferOrderAllocation.objects.filter(
line__in=[line.pk for line in self.lines.all()]
)
def is_fully_allocated(self) -> bool:
"""Return True if all line items are fully allocated."""
return all(line.is_fully_allocated() for line in self.lines.all())
def is_overallocated(self) -> bool:
"""Return true if any lines in the order are over-allocated."""
return any(line.is_overallocated() for line in self.lines.all())
def is_completed(self) -> bool:
"""Check if this order is "transferred" (all line items transferred)."""
return all(line.is_completed() for line in self.lines.all())
def can_complete(
self, raise_error: bool = False, allow_incomplete_lines: bool = False
) -> bool:
"""Test if this TransferOrder can be completed."""
try:
if self.status == TransferOrderStatus.COMPLETE.value:
raise ValidationError(_('Order is already complete'))
if self.status == TransferOrderStatus.CANCELLED.value:
raise ValidationError(_('Order is already cancelled'))
if not self.consume and not self.destination:
raise ValidationError(
_('Order cannot be completed until a destination location is set')
)
if not (self.is_fully_allocated() or allow_incomplete_lines):
raise ValidationError(
_('Order cannot be completed until it is fully allocated')
)
except ValidationError as e:
if raise_error:
raise e
else:
return False
return True
@property
def can_issue(self) -> bool:
"""Return True if this order can be issued."""
return self.status in [
TransferOrderStatus.PENDING.value,
TransferOrderStatus.ON_HOLD.value,
]
@transaction.atomic
def issue_order(self):
"""Attempt to transition to PLACED status."""
return self.handle_transition(
self.status, TransferOrderStatus.ISSUED.value, self, self._action_issue
)
# region state changes
def _action_issue(self, *args, **kwargs):
"""Marks the TransferOrder as ISSUED.
Order must be currently PENDING.
"""
if self.can_issue:
self.status = TransferOrderStatus.ISSUED.value
self.issue_date = InvenTree.helpers.current_date()
self.save()
trigger_event(TransferOrderEvents.ISSUED, id=self.pk)
# Notify users that the order has been issued
notify_responsible(
self,
TransferOrder,
exclude=self.created_by,
content=InvenTreeNotificationBodies.NewOrder,
extra_users=self.subscribed_users(),
)
@property
def can_hold(self) -> bool:
"""Return True if this order can be placed on hold."""
return self.status in [
TransferOrderStatus.PENDING.value,
TransferOrderStatus.ISSUED.value,
]
def _action_hold(self, *args, **kwargs):
"""Mark this transfer order as 'on hold'."""
if self.can_hold:
self.status = TransferOrderStatus.ON_HOLD.value
self.save()
trigger_event(TransferOrderEvents.HOLD, id=self.pk)
@transaction.atomic
def _action_complete(self, *args, **kwargs):
"""Marks the TransferOrder as COMPLETE.
Order must be currently ISSUED.
"""
user = kwargs.pop('user', None)
if not self.can_complete(raise_error=True, **kwargs):
return False
if self.status == TransferOrderStatus.ISSUED:
for allocation in self.allocations():
# execute each transfer
allocation.complete_allocation(user)
self.status = TransferOrderStatus.COMPLETE.value
self.complete_date = InvenTree.helpers.current_date()
self.save()
trigger_event(TransferOrderEvents.COMPLETED, id=self.pk)
return True
@transaction.atomic
def complete_order(self, user, **kwargs):
"""Attempt to transition to COMPLETE status."""
return self.handle_transition(
self.status,
TransferOrderStatus.COMPLETE.value,
self,
self._action_complete,
user=user,
**kwargs,
)
@transaction.atomic
def hold_order(self):
"""Attempt to transition to ON_HOLD status."""
return self.handle_transition(
self.status, TransferOrderStatus.ON_HOLD.value, self, self._action_hold
)
@transaction.atomic
def cancel_order(self):
"""Attempt to transition to CANCELLED status."""
return self.handle_transition(
self.status, TransferOrderStatus.CANCELLED.value, self, self._action_cancel
)
@property
def can_cancel(self) -> bool:
"""A TransferOrder can only be cancelled under the following circumstances.
- Status is ISSUED
- Status is PENDING (or ON_HOLD)
"""
return self.status in TransferOrderStatusGroups.OPEN
def _action_cancel(self, *args, **kwargs):
"""Cancel this TransferOrder (only if we're allowed to).
Executes:
- Mark the order as 'cancelled'
- Delete any StockItems which have been allocated
"""
if not self.can_cancel:
return False
self.status = TransferOrderStatus.CANCELLED.value
self.save()
# delete allocations
for line in self.lines.all():
for allocation in line.allocations.all():
allocation.delete()
trigger_event(TransferOrderEvents.CANCELLED, id=self.pk)
# Notify users that the order has been canceled
notify_responsible(
self,
TransferOrder,
exclude=self.created_by,
content=InvenTreeNotificationBodies.OrderCanceled,
extra_users=self.subscribed_users(),
)
# endregion
@property
def line_count(self) -> int:
"""Return the total number of lines associated with this order."""
return self.lines.count()
def completed_line_items(self) -> QuerySet:
"""Return a queryset of the completed line items for this order."""
return self.lines.filter(transferred__gte=F('quantity'))
def pending_line_items(self) -> QuerySet:
"""Return a queryset of the pending line items for this order."""
return self.lines.filter(transferred__lt=F('quantity'))
@property
def completed_line_count(self) -> int:
"""Return the number of completed lines for this order."""
return self.completed_line_items().count()
@property
def pending_line_count(self) -> int:
"""Return the number of pending (incomplete) lines associated with this order."""
return self.pending_line_items().count()
def allocations(self) -> QuerySet:
"""Return a queryset of all allocations for this order."""
return TransferOrderAllocation.objects.filter(line__order=self)
class TransferOrderLineItem(OrderLineItem):
"""Model for a single LineItem in a TransferOrder.
Attributes:
order: Link to the TransferOrder that this line item belongs to
part: Link to a Part object (may be null)
transferred: The number of items which have actually transferred against this line item
"""
class Meta:
"""Model meta options."""
verbose_name = _('Transfer Order Line Item')
# Filter for determining if a particular TransferOrderLineItem is overdue
OVERDUE_FILTER = (
Q(transferred__lt=F('quantity'))
& ~Q(target_date=None)
& Q(target_date__lt=InvenTree.helpers.current_date())
)
@staticmethod
def get_api_url():
"""Return the API URL associated with the TransferOrderLineItem model."""
return reverse('api-transfer-order-line-list')
order = models.ForeignKey(
TransferOrder,
on_delete=models.CASCADE,
related_name='lines',
verbose_name=_('Order'),
help_text=_('Transfer Order'),
)
part = models.ForeignKey(
'part.Part',
on_delete=models.SET_NULL,
related_name='transfer_order_line_items',
null=True,
verbose_name=_('Part'),
help_text=_('Part'),
# limit_choices_to={'salable': True},
)
transferred = RoundingDecimalField(
verbose_name=_('transferred'),
help_text=_('transferred quantity'),
default=0,
max_digits=15,
decimal_places=5,
validators=[MinValueValidator(0)],
)
def allocated_quantity(self):
"""Return the total stock quantity allocated to this LineItem.
This is a summation of the quantity of each attached StockItem
"""
if not self.pk:
return 0
query = self.allocations.aggregate(
allocated=Coalesce(Sum('quantity'), Decimal(0))
)
return query['allocated']
def is_fully_allocated(self) -> bool:
"""Return True if this line item is fully allocated."""
# If the linked part is "virtual", then we cannot allocate stock against it
if self.part and self.part.virtual:
return True
return self.allocated_quantity() >= self.quantity
def is_overallocated(self) -> bool:
"""Return True if this line item is over allocated."""
return self.allocated_quantity() > self.quantity
def is_completed(self) -> bool:
"""Return True if this line item is completed (has been fully shipped)."""
# A "virtual" part is always considered to be "completed"
if self.part and self.part.virtual:
return True
return self.transferred >= self.quantity
class TransferOrderAllocation(models.Model):
"""This model is used to 'allocate' stock items to a TransferOrder. Items that are "allocated" to a TransferOrder are not yet "attached" to the order, but they will be once the order is fulfilled.
Attributes:
line: TransferOrderLineItem reference
item: StockItem reference
quantity: Quantity to take from the StockItem
"""
class Meta:
"""Model meta options."""
verbose_name = _('Transfer Order Allocation')
@staticmethod
def get_api_url():
"""Return the API URL associated with the TransferOrderAllocation model."""
return reverse('api-transfer-order-allocation-list')
def clean(self):
"""Validate the TransferOrderAllocation object.
Executes:
- Cannot allocate stock to a line item without a part reference
- The referenced part must match the part associated with the line item
- Allocated quantity cannot exceed the quantity of the stock item
- Allocation quantity must be "1" if the StockItem is serialized
- Allocation quantity cannot be zero
"""
super().clean()
errors = {}
try:
if not self.item:
raise ValidationError({'item': _('Stock item has not been assigned')})
except stock.models.StockItem.DoesNotExist:
raise ValidationError({'item': _('Stock item has not been assigned')})
try:
if self.line.part != self.item.part:
variants = self.line.part.get_descendants(include_self=True)
if self.line.part not in variants:
errors['item'] = _(
'Cannot allocate stock item to a line with a different part'
)
except PartModels.Part.DoesNotExist:
errors['line'] = _('Cannot allocate stock to a line without a part')
if self.quantity > self.item.quantity:
errors['quantity'] = _('Allocation quantity cannot exceed stock quantity')
# Ensure that we do not 'over allocate' a stock item
build_allocation_count = self.item.build_allocation_count()
sales_allocation_count = self.item.sales_order_allocation_count(
exclude_allocations={'pk': self.pk}
)
total_allocation = (
build_allocation_count + sales_allocation_count + self.quantity
)
if total_allocation > self.item.quantity:
errors['quantity'] = _('Stock item is over-allocated')
if self.quantity <= 0:
errors['quantity'] = _('Allocation quantity must be greater than zero')
if self.item.serial and self.quantity != 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
if len(errors) > 0:
raise ValidationError(errors)
line = models.ForeignKey(
TransferOrderLineItem,
on_delete=models.CASCADE,
verbose_name=_('Line'),
related_name='allocations',
)
item = models.ForeignKey(
'stock.StockItem',
on_delete=models.CASCADE,
related_name='transfer_order_allocations',
limit_choices_to={
'part__virtual': False,
'belongs_to': None,
'sales_order': None,
},
verbose_name=_('Item'),
help_text=_('Select stock item to allocate'),
)
quantity = RoundingDecimalField(
max_digits=15,
decimal_places=5,
validators=[MinValueValidator(0)],
default=1,
verbose_name=_('Quantity'),
help_text=_('Enter stock allocation quantity'),
)
def get_location(self):
"""Return the <pk> value of the location associated with this allocation."""
return self.item.location.id if self.item.location else None
def get_po(self):
"""Return the PurchaseOrder associated with this allocation."""
return self.item.purchase_order
def complete_allocation(self, user):
"""Complete this allocation (called when the parent TransferOrder is marked as "completed").
Executes:
- Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity)
- Move the StockItem to the new location
- Updates the transferred qty
- If order is marked as "consume", reduce quantity rather than move
"""
order: TransferOrder = self.line.order
self.item: stock.models.StockItem # for type hints
self.line: TransferOrderLineItem # for type hints
# The allocation is the only thing linking this stock item to the transfer
# As a result, we must keep the allocation present even after completion
# This means allocations to transfer orders don't affect "available" stock
# (otherwise it would permanently reduce available stock)
if order.consume:
# rather than transferring the stock, we simply reduce its quantity to release it from tracked inventory
# NOTE: if delete_on_deplete is enabled, this will result in the "transferred stock" panel being empty
# after completion. A more sophesticated immutable tracking that doesn't rely on allocations
# would be helpful here
self.item.take_stock(
quantity=self.quantity,
user=user,
code=StockHistoryCode.STOCK_REMOVE,
transferorder=order,
)
else:
if self.quantity < self.item.quantity:
# update our own reference to the StockItem which was split
self.item = self.item.splitStock(
quantity=self.quantity,
location=order.destination,
user=user,
transferorder=order,
)
self.save()
else:
# move item directly, we don't have to split
self.item.move(
location=order.destination, user=user, transferorder=order, notes=''
)
# Update the transferred qty
self.line.transferred += self.quantity
self.line.save()
def _touch_order_updated_at(instance):
"""Bump updated_at on the parent order without triggering a full save."""
if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
@@ -3190,6 +3851,16 @@ def _touch_order_updated_at(instance):
@receiver(
post_delete, sender=ReturnOrderExtraLine, dispatch_uid='ro_extraline_post_delete'
)
@receiver(
post_save,
sender=TransferOrderLineItem,
dispatch_uid='transfer_order_lineitem_post_save',
)
@receiver(
post_delete,
sender=TransferOrderLineItem,
dispatch_uid='transfer_order_lineitem_post_delete',
)
def update_order_on_lineitem_change(sender, instance, **kwargs):
"""Update parent order updated_at when any line item is saved or deleted."""
_touch_order_updated_at(instance)