mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-01 17:51:23 +00:00
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:
co-authored by
Oliver
Matthias Mair
parent
5489656016
commit
74d9ab6d11
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user