Coverage for src/backend/InvenTree/order/models.py: 36%
1381 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Order model definitions."""
3from decimal import Decimal
4from typing import Any, Optional, TypedDict
6from django.contrib.auth.models import User
7from django.core.exceptions import ValidationError
8from django.core.validators import MinValueValidator
9from django.db import models, transaction
10from django.db.models import F, Q, QuerySet, Sum
11from django.db.models.functions import Coalesce
12from django.db.models.signals import post_delete, post_save
13from django.dispatch.dispatcher import receiver
14from django.urls import reverse
15from django.utils.translation import gettext_lazy as _
17import structlog
18from djmoney.contrib.exchange.exceptions import MissingRate
19from djmoney.contrib.exchange.models import convert_money
20from djmoney.money import Money
21from mptt.models import TreeForeignKey
23import common.models as common_models
24import InvenTree.helpers
25import InvenTree.models
26import InvenTree.ready
27import InvenTree.tasks
28import InvenTree.validators
29import order.validators
30import report.mixins
31import stock.models
32import users.models as UserModels
33from build.status_codes import BuildStatus
34from common.currency import currency_code_default
35from common.notifications import InvenTreeNotificationBodies
36from common.settings import get_global_setting
37from company.models import Address, Company, Contact, SupplierPart
38from generic.states import StateTransitionMixin, StatusCodeMixin
39from generic.states.fields import InvenTreeCustomStatusModelField
40from InvenTree.exceptions import log_error
41from InvenTree.fields import (
42 InvenTreeModelMoneyField,
43 InvenTreeURLField,
44 RoundingDecimalField,
45)
46from InvenTree.helpers import decimal2string, pui_url
47from InvenTree.helpers_model import notify_responsible
48from order.events import (
49 PurchaseOrderEvents,
50 ReturnOrderEvents,
51 SalesOrderEvents,
52 TransferOrderEvents,
53)
54from order.status_codes import (
55 PurchaseOrderStatus,
56 PurchaseOrderStatusGroups,
57 ReturnOrderLineStatus,
58 ReturnOrderStatus,
59 ReturnOrderStatusGroups,
60 SalesOrderStatus,
61 SalesOrderStatusGroups,
62 TransferOrderStatus,
63 TransferOrderStatusGroups,
64)
65from part import models as PartModels
66from plugin.events import trigger_event
67from stock.status_codes import StockHistoryCode, StockStatus
69logger = structlog.get_logger('inventree')
72class TotalPriceMixin(models.Model):
73 """Mixin which provides 'total_price' field for an order."""
75 class Meta:
76 """Meta for MetadataMixin."""
78 abstract = True
80 def save(self, *args, **kwargs):
81 """Update the total_price field when saved."""
82 # Recalculate total_price for this order
83 self.update_total_price(commit=False)
85 if hasattr(self, '_SAVING_TOTAL_PRICE') and self._SAVING_TOTAL_PRICE:
86 # Avoid recursion on save
87 return super().save(*args, **kwargs)
88 self._SAVING_TOTAL_PRICE = True
90 # Save the object as we can not access foreign/m2m fields before saving
91 self.update_total_price(commit=True)
93 total_price = InvenTreeModelMoneyField(
94 null=True,
95 blank=True,
96 allow_negative=False,
97 verbose_name=_('Total Price'),
98 help_text=_('Total price for this order'),
99 )
101 order_currency = models.CharField(
102 max_length=3,
103 verbose_name=_('Order Currency'),
104 blank=True,
105 null=True,
106 help_text=_('Currency for this order (leave blank to use company default)'),
107 validators=[InvenTree.validators.validate_currency_code],
108 )
110 @property
111 def currency(self):
112 """Return the currency associated with this order instance.
114 Rules:
115 - If the order_currency field is set, return that
116 - Otherwise, return the currency associated with the company
117 - Finally, return the default currency code
118 """
119 if self.order_currency:
120 return self.order_currency
122 if self.company:
123 return self.company.currency_code
125 # Return default currency code
126 return currency_code_default()
128 def update_total_price(self, commit=True):
129 """Recalculate and save the total_price for this order."""
130 self.total_price = self.calculate_total_price(target_currency=self.currency)
132 if commit:
133 self.save()
135 def calculate_total_price(self, target_currency=None):
136 """Calculates the total price of all order lines, and converts to the specified target currency.
138 If not specified, the default system currency is used.
140 If currency conversion fails (e.g. there are no valid conversion rates),
141 then we simply return zero, rather than attempting some other calculation.
142 """
143 # Set default - see B008
144 if target_currency is None:
145 target_currency = currency_code_default()
147 total = Money(0, target_currency)
149 # Check if the order has been saved (otherwise we can't calculate the total price)
150 if self.pk is None:
151 return total
153 # order items
154 for line in self.lines.all():
155 if not line.price:
156 continue
158 try:
159 total += line.quantity * convert_money(line.price, target_currency)
160 except MissingRate:
161 log_error('order.calculate_total_price')
162 logger.exception("Missing exchange rate for '%s'", target_currency)
164 # Return None to indicate the calculated price is invalid
165 return None
167 # extra items
168 for line in self.extra_lines.all():
169 if not line.price:
170 continue
172 try:
173 total += line.quantity * convert_money(line.price, target_currency)
174 except MissingRate:
175 # Record the error, try to press on
177 log_error('order.calculate_total_price')
178 logger.exception("Missing exchange rate for '%s'", target_currency)
180 # Return None to indicate the calculated price is invalid
181 return None
183 # set decimal-places
184 total.decimal_places = 4
186 return total
189class BaseOrderReportContext(report.mixins.BaseReportContext, TypedDict):
190 """Base context for all order models.
192 Attributes:
193 description: The description field of the order
194 extra_lines: Query set of all extra lines associated with the order
195 lines: Query set of all line items associated with the order
196 order: The order instance itself
197 reference: The reference field of the order
198 title: The title (string representation) of the order
199 """
201 description: str
202 extra_lines: Any
203 lines: Any
204 order: Any
205 reference: str
206 title: str
209class PurchaseOrderReportContext(report.mixins.BaseReportContext, TypedDict):
210 """Context for the purchase order model.
212 Attributes:
213 description: The description field of the PurchaseOrder
214 reference: The reference field of the PurchaseOrder
215 title: The title (string representation) of the PurchaseOrder
216 extra_lines: Query set of all extra lines associated with the PurchaseOrder
217 lines: Query set of all line items associated with the PurchaseOrder
218 order: The PurchaseOrder instance itself
219 supplier: The supplier object associated with the PurchaseOrder
220 """
222 description: str
223 reference: str
224 title: str
225 extra_lines: report.mixins.QuerySet['PurchaseOrderExtraLine']
226 lines: report.mixins.QuerySet['PurchaseOrderLineItem']
227 order: 'PurchaseOrder'
228 supplier: Optional[Company]
231class SalesOrderReportContext(report.mixins.BaseReportContext, TypedDict):
232 """Context for the sales order model.
234 Attributes:
235 description: The description field of the SalesOrder
236 reference: The reference field of the SalesOrder
237 title: The title (string representation) of the SalesOrder
238 extra_lines: Query set of all extra lines associated with the SalesOrder
239 lines: Query set of all line items associated with the SalesOrder
240 order: The SalesOrder instance itself
241 customer: The customer object associated with the SalesOrder
242 """
244 description: str
245 reference: str
246 title: str
247 extra_lines: report.mixins.QuerySet['SalesOrderExtraLine']
248 lines: report.mixins.QuerySet['SalesOrderLineItem']
249 order: 'SalesOrder'
250 customer: Optional[Company]
253class ReturnOrderReportContext(report.mixins.BaseReportContext, TypedDict):
254 """Context for the return order model.
256 Attributes:
257 description: The description field of the ReturnOrder
258 reference: The reference field of the ReturnOrder
259 title: The title (string representation) of the ReturnOrder
260 extra_lines: Query set of all extra lines associated with the ReturnOrder
261 lines: Query set of all line items associated with the ReturnOrder
262 order: The ReturnOrder instance itself
263 customer: The customer object associated with the ReturnOrder
264 """
266 description: str
267 reference: str
268 title: str
269 extra_lines: report.mixins.QuerySet['ReturnOrderExtraLine']
270 lines: report.mixins.QuerySet['ReturnOrderLineItem']
271 order: 'ReturnOrder'
272 customer: Optional[Company]
275class TransferOrderReportContext(BaseOrderReportContext, TypedDict):
276 """Context for the transfer order model.
278 Attributes:
279 description: The description field of the TransferOrder
280 reference: The reference field of the TransferOrder
281 title: The title (string representation) of the TransferOrder
282 lines: Query set of all line items associated with the TransferOrder
283 order: The TransferOrder instance itself
284 """
286 description: str
287 reference: str
288 title: str
289 lines: report.mixins.QuerySet['TransferOrderLineItem']
290 order: 'TransferOrder'
291 take_from: 'stock.models.StockLocation'
292 destination: 'stock.models.StockLocation'
293 consume: bool
296class Order(
297 StatusCodeMixin,
298 StateTransitionMixin,
299 InvenTree.models.InvenTreeParameterMixin,
300 InvenTree.models.InvenTreeAttachmentMixin,
301 InvenTree.models.InvenTreeBarcodeMixin,
302 InvenTree.models.InvenTreeNotesMixin,
303 InvenTree.models.InvenTreeTagsMixin,
304 report.mixins.InvenTreeReportMixin,
305 InvenTree.models.MetadataMixin,
306 InvenTree.models.ReferenceIndexingMixin,
307 InvenTree.models.InvenTreeModel,
308):
309 """Abstract model for an order.
311 Instances of this class:
313 - PurchaseOrder
314 - SalesOrder
316 Attributes:
317 reference: Unique order number / reference / code
318 description: Long form description (required)
319 notes: Extra note field (optional)
320 creation_date: Automatic date of order creation
321 created_by: User who created this order (automatically captured)
322 issue_date: Date the order was issued
323 start_date: Date the order is scheduled to be started
324 target_date: Expected or desired completion date
325 complete_date: Date the order was completed
326 responsible: User (or group) responsible for managing the order
327 """
329 REQUIRE_RESPONSIBLE_SETTING = None
330 UNLOCK_SETTING = None
331 IMPORT_ID_FIELDS = ['reference']
333 class Meta:
334 """Metaclass options. Abstract ensures no database table is created."""
336 abstract = True
338 def save(self, *args, **kwargs):
339 """Custom save method for the order models.
341 Enforces various business logics:
342 - Ensures the object is not locked
343 - Ensures that the reference field is rebuilt whenever the instance is saved.
344 """
345 # check if we are updating the model, not adding it
346 update = self.pk is not None
348 # Locking
349 if update and self.check_locked(True): 349 ↛ 351line 349 didn't jump to line 351 because the condition on line 349 was never true
350 # Ensure that order status can be changed still
351 if self.get_db_instance().status != self.status:
352 pass
353 else:
354 raise ValidationError({
355 'reference': _('This order is locked and cannot be modified')
356 })
358 # Reference calculations
359 self.reference_int = self.rebuild_reference_field(self.reference)
360 if not self.creation_date:
361 self.creation_date = InvenTree.helpers.current_date()
363 self.updated_at = InvenTree.helpers.current_time()
365 super().save(*args, **kwargs)
367 def check_locked(self, db: bool = False) -> bool:
368 """Check if this order is 'locked'.
370 A locked order cannot be modified after it has been completed.
372 Arguments:
373 db: If True, check with the database. If False, check the instance (default False).
374 """
375 if not self.check_complete(db=db): 375 ↛ 379line 375 didn't jump to line 379 because the condition on line 375 was always true
376 # If the order is not complete, it is not locked
377 return False
379 if self.UNLOCK_SETTING:
380 return get_global_setting(self.UNLOCK_SETTING, backup_value=False) is False
382 return False
384 def check_complete(self, db: bool = False) -> bool:
385 """Check if this order is 'complete'.
387 Arguments:
388 db: If True, check with the database. If False, check the instance (default False).
389 """
390 status = self.get_db_instance().status if db else self.status
391 return status in self.get_status_class().COMPLETE
393 def clean(self):
394 """Custom clean method for the generic order class."""
395 super().clean()
397 # Check if a responsible owner is required for this order type
398 if self.REQUIRE_RESPONSIBLE_SETTING: 398 ↛ 406line 398 didn't jump to line 406 because the condition on line 398 was always true
399 if get_global_setting(self.REQUIRE_RESPONSIBLE_SETTING, backup_value=False): 399 ↛ 400line 399 didn't jump to line 400 because the condition on line 399 was never true
400 if not self.responsible:
401 raise ValidationError({
402 'responsible': _('Responsible user or group must be specified')
403 })
405 # Check that the referenced 'contact' matches the correct 'company'
406 if ( 406 ↛ 413line 406 didn't jump to line 413 because the condition on line 406 was never true
407 hasattr(self, 'company')
408 and hasattr(self, 'contact')
409 and self.company
410 and self.contact
411 and (self.contact.company != self.company)
412 ):
413 raise ValidationError({
414 'contact': _('Contact does not match selected company')
415 })
417 # Target date should be *after* the start date
418 if self.start_date and self.target_date and self.start_date > self.target_date: 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true
419 raise ValidationError({
420 'target_date': _('Target date must be after start date'),
421 'start_date': _('Start date must be before target date'),
422 })
424 # Check that the referenced 'address' matches the correct 'company'
425 if ( 425 ↛ 431line 425 didn't jump to line 431 because the condition on line 425 was never true
426 hasattr(self, 'company')
427 and self.company
428 and self.address
429 and (self.address.company != self.company)
430 ):
431 raise ValidationError({
432 'address': _('Address does not match selected company')
433 })
435 def clean_line_item(self, line):
436 """Clean a line item for this order.
438 Used when duplicating an existing line item,
439 to ensure it is 'fresh'.
440 """
441 line.pk = None
442 line.target_date = None
443 line.order = self
445 def report_context(self) -> BaseOrderReportContext:
446 """Generate context data for the reporting interface."""
447 return {
448 'description': self.description,
449 'extra_lines': getattr(
450 self, 'extra_lines', None
451 ), # Transfer Order doesn't have extra lines
452 'lines': self.lines,
453 'order': self,
454 'reference': self.reference,
455 'title': str(self),
456 }
458 @classmethod
459 def overdue_filter(cls):
460 """A generic implementation of an 'overdue' filter for the Model class.
462 It requires any subclasses to implement the get_status_class() class method
463 """
464 today = InvenTree.helpers.current_date()
465 return (
466 Q(status__in=cls.get_status_class().OPEN)
467 & ~Q(target_date=None)
468 & Q(target_date__lt=today)
469 )
471 @property
472 def is_overdue(self):
473 """Method to determine if this order is overdue.
475 Makes use of the overdue_filter() method to avoid code duplication
476 """
477 return (
478 self.__class__.objects
479 .filter(pk=self.pk)
480 .filter(self.__class__.overdue_filter())
481 .exists()
482 )
484 description = models.CharField(
485 max_length=250,
486 blank=True,
487 verbose_name=_('Description'),
488 help_text=_('Order description (optional)'),
489 )
491 project_code = models.ForeignKey(
492 common_models.ProjectCode,
493 on_delete=models.SET_NULL,
494 blank=True,
495 null=True,
496 verbose_name=_('Project Code'),
497 help_text=_('Select project code for this order'),
498 )
500 link = InvenTreeURLField(
501 blank=True,
502 verbose_name=_('Link'),
503 help_text=_('Link to external page'),
504 max_length=2000,
505 )
507 start_date = models.DateField(
508 null=True,
509 blank=True,
510 verbose_name=_('Start date'),
511 help_text=_('Scheduled start date for this order'),
512 )
514 target_date = models.DateField(
515 blank=True,
516 null=True,
517 verbose_name=_('Target Date'),
518 help_text=_(
519 'Expected date for order delivery. Order will be overdue after this date.'
520 ),
521 )
523 creation_date = models.DateField(
524 blank=True, null=True, verbose_name=_('Creation Date')
525 )
527 created_by = models.ForeignKey(
528 User,
529 on_delete=models.SET_NULL,
530 blank=True,
531 null=True,
532 related_name='+',
533 verbose_name=_('Created By'),
534 )
536 issue_date = models.DateField(
537 blank=True,
538 null=True,
539 verbose_name=_('Issue Date'),
540 help_text=_('Date order was issued'),
541 )
543 updated_at = models.DateTimeField(
544 null=True,
545 blank=True,
546 verbose_name=_('Updated At'),
547 help_text=_('Timestamp of last update'),
548 )
550 responsible = models.ForeignKey(
551 UserModels.Owner,
552 on_delete=models.SET_NULL,
553 blank=True,
554 null=True,
555 help_text=_('User or group responsible for this order'),
556 verbose_name=_('Responsible'),
557 related_name='+',
558 )
560 contact = models.ForeignKey(
561 Contact,
562 on_delete=models.SET_NULL,
563 blank=True,
564 null=True,
565 verbose_name=_('Contact'),
566 help_text=_('Point of contact for this order'),
567 related_name='+',
568 )
570 address = models.ForeignKey(
571 Address,
572 on_delete=models.SET_NULL,
573 blank=True,
574 null=True,
575 verbose_name=_('Address'),
576 help_text=_('Company address for this order'),
577 related_name='+',
578 )
580 @property
581 def company(self):
582 """Return the company associated with this order.
584 This method must be implemented by any subclass, as the 'company' field may be named differently for different order types (e.g. supplier vs customer).
585 """
586 raise NotImplementedError(f'company() method not implemented for {__class__}')
588 @property
589 def order_address(self):
590 """Return the Address associated with this order."""
591 return self.address or self.company.primary_address
593 @property
594 def status_text(self):
595 """Return the text representation of the current status. This will consider any custom status."""
596 if self.get_custom_status() is not None:
597 from generic.states.custom import (
598 get_logical_value as get_custom_state_logical_value,
599 )
601 custom_status = get_custom_state_logical_value(
602 self.get_custom_status(), model=self._meta.model_name
603 )
604 return custom_status.label
605 else:
606 return self.status_class.label(self.get_status())
608 @classmethod
609 def get_status_class(cls):
610 """Return the enumeration class which represents the 'status' field for this model."""
611 raise NotImplementedError(f'get_status_class() not implemented for {__class__}')
614class PurchaseOrder(TotalPriceMixin, Order):
615 """A PurchaseOrder represents goods shipped inwards from an external supplier.
617 Attributes:
618 supplier: Reference to the company supplying the goods in the order
619 supplier_reference: Optional field for supplier order reference code
620 received_by: User that received the goods
621 target_date: Expected delivery target date for PurchaseOrder completion (optional)
622 """
624 REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
625 REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE'
626 STATUS_CLASS = PurchaseOrderStatus
627 UNLOCK_SETTING = 'PURCHASEORDER_EDIT_COMPLETED_ORDERS'
629 class Meta:
630 """Model meta options."""
632 verbose_name = _('Purchase Order')
634 def clean_line_item(self, line):
635 """Clean a line item for this PurchaseOrder."""
636 super().clean_line_item(line)
637 line.received = 0
639 def report_context(self) -> PurchaseOrderReportContext:
640 """Return report context data for this PurchaseOrder."""
641 return_ctx = super().report_context()
643 return_ctx.update({'supplier': self.supplier}) # ty:ignore[invalid-key]
644 return return_ctx
646 def get_absolute_url(self) -> str:
647 """Get the 'web' URL for this order."""
648 return pui_url(f'/purchasing/purchase-order/{self.pk}')
650 @staticmethod
651 def get_api_url() -> str:
652 """Return the API URL associated with the PurchaseOrder model."""
653 return reverse('api-po-list')
655 @classmethod
656 def get_status_class(cls):
657 """Return the PurchaseOrderStatus class."""
658 return PurchaseOrderStatusGroups
660 @classmethod
661 def api_defaults(cls, request=None):
662 """Return default values for this model when issuing an API OPTIONS request."""
663 defaults = {
664 'reference': order.validators.generate_next_purchase_order_reference()
665 }
667 return defaults
669 @classmethod
670 def barcode_model_type_code(cls) -> str:
671 """Return the associated barcode model type code for this model."""
672 return 'PO'
674 def subscribed_users(self) -> list[User]:
675 """Return a list of users subscribed to this PurchaseOrder.
677 By this, we mean users to are interested in any of the parts associated with this order.
678 """
679 subscribed_users = set()
681 for line in self.lines.all():
682 if line.part and line.part.part:
683 # Add the part to the list of subscribed users
684 for user in line.part.part.get_subscribers():
685 subscribed_users.add(user)
687 return list(subscribed_users)
689 def __str__(self):
690 """Render a string representation of this PurchaseOrder."""
691 return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}'
693 reference = models.CharField(
694 unique=True,
695 max_length=64,
696 blank=False,
697 verbose_name=_('Reference'),
698 help_text=_('Order reference'),
699 default=order.validators.generate_next_purchase_order_reference,
700 validators=[order.validators.validate_purchase_order_reference],
701 )
703 status = InvenTreeCustomStatusModelField(
704 default=PurchaseOrderStatus.PENDING.value,
705 choices=PurchaseOrderStatus.items(),
706 status_class=PurchaseOrderStatus,
707 verbose_name=_('Status'),
708 help_text=_('Purchase order status'),
709 )
711 supplier = models.ForeignKey(
712 Company,
713 on_delete=models.SET_NULL,
714 null=True,
715 limit_choices_to={'is_supplier': True},
716 related_name='purchase_orders',
717 verbose_name=_('Supplier'),
718 help_text=_('Company from which the items are being ordered'),
719 )
721 @property
722 def company(self):
723 """Accessor helper for Order base class."""
724 return self.supplier
726 supplier_reference = models.CharField(
727 max_length=64,
728 blank=True,
729 verbose_name=_('Supplier Reference'),
730 help_text=_('Supplier order reference code'),
731 )
733 received_by = models.ForeignKey(
734 User,
735 on_delete=models.SET_NULL,
736 blank=True,
737 null=True,
738 related_name='+',
739 verbose_name=_('received by'),
740 )
742 complete_date = models.DateField(
743 blank=True,
744 null=True,
745 verbose_name=_('Completion Date'),
746 help_text=_('Date order was completed'),
747 )
749 destination = TreeForeignKey(
750 'stock.StockLocation',
751 on_delete=models.SET_NULL,
752 related_name='purchase_orders',
753 blank=True,
754 null=True,
755 verbose_name=_('Destination'),
756 help_text=_('Destination for received items'),
757 )
759 @transaction.atomic
760 def add_line_item(
761 self,
762 supplier_part,
763 quantity,
764 group: bool = True,
765 reference: str = '',
766 purchase_price=None,
767 destination=None,
768 ):
769 """Add a new line item to this purchase order.
771 This function will check that:
772 * The supplier part matches the supplier specified for this purchase order
773 * The quantity is greater than zero
775 Arguments:
776 supplier_part: The supplier_part to add
777 quantity : The number of items to add
778 group (bool, optional): If True, this new quantity will be added to an existing line item for the same supplier_part (if it exists). Defaults to True.
779 reference (str, optional): Reference to item. Defaults to ''.
780 purchase_price (optional): Price of item. Defaults to None.
781 destination (optional): Destination for item. Defaults to None.
783 Returns:
784 The newly created PurchaseOrderLineItem instance
786 Raises:
787 ValidationError: quantity is smaller than 0
788 ValidationError: quantity is not type int
789 ValidationError: supplier is not supplier of purchase order
790 """
791 try:
792 quantity = int(quantity)
793 if quantity <= 0:
794 raise ValidationError({
795 'quantity': _('Quantity must be greater than zero')
796 })
797 except ValueError:
798 raise ValidationError({'quantity': _('Invalid quantity provided')})
800 if supplier_part.supplier != self.supplier:
801 raise ValidationError({
802 'supplier': _('Part supplier must match PO supplier')
803 })
805 if group:
806 # Check if there is already a matching line item (for this PurchaseOrder)
807 # Lock the matching row, so concurrent additions cannot both read
808 # the same starting quantity (lost update)
809 matches = self.lines.select_for_update().filter(part=supplier_part)
811 if matches.count() > 0:
812 line = matches.first()
814 # update quantity and price
815 quantity_new = line.quantity + quantity
816 line.quantity = quantity_new
817 supplier_price = supplier_part.get_price(quantity_new)
819 if line.purchase_price and supplier_price:
820 line.purchase_price = supplier_price / quantity_new
822 line.save()
824 return line
826 line = PurchaseOrderLineItem(
827 order=self,
828 part=supplier_part,
829 quantity=quantity,
830 reference=reference,
831 purchase_price=purchase_price,
832 destination=destination,
833 )
835 line.save()
837 return line
839 # region state changes
840 def _action_place(self, *args, **kwargs):
841 """Marks the PurchaseOrder as PLACED.
843 Order must be currently PENDING.
844 """
845 if self.can_issue:
846 self.status = PurchaseOrderStatus.PLACED.value
847 self.issue_date = InvenTree.helpers.current_date()
848 self.save()
850 trigger_event(PurchaseOrderEvents.PLACED, id=self.pk)
852 # Notify users that the order has been placed
853 notify_responsible(
854 self,
855 PurchaseOrder,
856 exclude=self.created_by,
857 content=InvenTreeNotificationBodies.NewOrder,
858 extra_users=self.subscribed_users(),
859 )
861 def _action_complete(self, *args, **kwargs):
862 """Marks the PurchaseOrder as COMPLETE.
864 Order must be currently PLACED.
865 """
866 if self.status == PurchaseOrderStatus.PLACED:
867 self.status = PurchaseOrderStatus.COMPLETE.value
868 self.complete_date = InvenTree.helpers.current_date()
870 self.save()
872 unique_parts = set()
874 # Schedule pricing update for any referenced parts
875 for line in self.lines.all().prefetch_related('part__part'):
876 # Ensure we only check 'unique' parts
877 if line.part and line.part.part:
878 unique_parts.add(line.part.part)
880 for part in unique_parts:
881 part.schedule_pricing_update(create=True, refresh=False)
883 trigger_event(PurchaseOrderEvents.COMPLETED, id=self.pk)
885 @transaction.atomic
886 def issue_order(self):
887 """Equivalent to 'place_order'."""
888 return self.place_order()
890 @property
891 def can_issue(self) -> bool:
892 """Return True if this order can be issued."""
893 return self.status in [
894 PurchaseOrderStatus.PENDING.value,
895 PurchaseOrderStatus.ON_HOLD.value,
896 ]
898 @transaction.atomic
899 def place_order(self):
900 """Attempt to transition to PLACED status."""
901 return self.handle_transition(
902 self.status, PurchaseOrderStatus.PLACED.value, self, self._action_place
903 )
905 @transaction.atomic
906 def complete_order(self):
907 """Attempt to transition to COMPLETE status."""
908 return self.handle_transition(
909 self.status, PurchaseOrderStatus.COMPLETE.value, self, self._action_complete
910 )
912 @transaction.atomic
913 def hold_order(self):
914 """Attempt to transition to ON_HOLD status."""
915 return self.handle_transition(
916 self.status, PurchaseOrderStatus.ON_HOLD.value, self, self._action_hold
917 )
919 @transaction.atomic
920 def cancel_order(self):
921 """Attempt to transition to CANCELLED status."""
922 return self.handle_transition(
923 self.status, PurchaseOrderStatus.CANCELLED.value, self, self._action_cancel
924 )
926 @property
927 def is_pending(self) -> bool:
928 """Return True if the PurchaseOrder is 'pending'."""
929 return self.status == PurchaseOrderStatus.PENDING.value
931 @property
932 def is_open(self) -> bool:
933 """Return True if the PurchaseOrder is 'open'."""
934 return self.status in PurchaseOrderStatusGroups.OPEN
936 @property
937 def can_cancel(self) -> bool:
938 """A PurchaseOrder can only be cancelled under the following circumstances.
940 - Status is PLACED
941 - Status is PENDING (or ON_HOLD)
942 """
943 return self.status in PurchaseOrderStatusGroups.OPEN
945 def _action_cancel(self, *args, **kwargs):
946 """Marks the PurchaseOrder as CANCELLED."""
947 if self.can_cancel:
948 self.status = PurchaseOrderStatus.CANCELLED.value
949 self.save()
951 trigger_event(PurchaseOrderEvents.CANCELLED, id=self.pk)
953 # Notify users that the order has been canceled
954 notify_responsible(
955 self,
956 PurchaseOrder,
957 exclude=self.created_by,
958 content=InvenTreeNotificationBodies.OrderCanceled,
959 extra_users=self.subscribed_users(),
960 )
962 @property
963 def can_hold(self) -> bool:
964 """Return True if this order can be placed on hold."""
965 return self.status in [
966 PurchaseOrderStatus.PENDING.value,
967 PurchaseOrderStatus.PLACED.value,
968 ]
970 def _action_hold(self, *args, **kwargs):
971 """Mark this purchase order as 'on hold'."""
972 if self.can_hold:
973 self.status = PurchaseOrderStatus.ON_HOLD.value
974 self.save()
976 trigger_event(PurchaseOrderEvents.HOLD, id=self.pk)
978 # endregion
980 def pending_line_items(self) -> QuerySet:
981 """Return a list of pending line items for this order.
983 Any line item where 'received' < 'quantity' will be returned.
984 """
985 return self.lines.filter(quantity__gt=F('received'))
987 def completed_line_items(self) -> QuerySet:
988 """Return a list of completed line items against this order."""
989 return self.lines.filter(quantity__lte=F('received'))
991 @property
992 def line_count(self) -> int:
993 """Return the total number of line items associated with this order."""
994 return self.lines.count()
996 @property
997 def completed_line_count(self) -> int:
998 """Return the number of complete line items associated with this order."""
999 return self.completed_line_items().count()
1001 @property
1002 def pending_line_count(self) -> int:
1003 """Return the number of pending line items associated with this order."""
1004 return self.pending_line_items().count()
1006 @property
1007 def is_complete(self) -> bool:
1008 """Return True if all line items have been received."""
1009 return self.pending_line_items().count() == 0
1011 @transaction.atomic
1012 def receive_line_items(
1013 self, location, items: list, user: User, **kwargs
1014 ) -> QuerySet:
1015 """Receive multiple line items against this PurchaseOrder.
1017 Arguments:
1018 location: The StockLocation to receive the items into
1019 items: A list of line item IDs and quantities to receive
1020 user: The User performing the action
1022 Returns:
1023 A QuerySet of the newly created StockItem objects
1025 The 'items' list values contain:
1026 line_item: The PurchaseOrderLineItem instance
1027 quantity: The quantity of items to receive
1028 location: The location to receive the item into (optional)
1029 status: The 'status' of the item
1030 barcode: Optional barcode for the item (optional)
1031 batch_code: Optional batch code for the item (optional)
1032 expiry_date: Optional expiry date for the item (optional)
1033 serials: Optional list of serial numbers (optional)
1034 note: Optional notes for the item (optional)
1035 """
1036 if self.status != PurchaseOrderStatus.PLACED:
1037 raise ValidationError(
1038 "Lines can only be received against an order marked as 'PLACED'"
1039 )
1041 # List of stock items which have been created
1042 stock_items: list[stock.models.StockItem] = []
1044 # List of stock items to bulk create
1045 bulk_create_items: list[stock.models.StockItem] = []
1047 # List of tracking entries to create
1048 tracking_entries: list[stock.models.StockItemTracking] = []
1050 # List of line items to update
1051 line_items_to_update: list[PurchaseOrderLineItem] = []
1053 # Set of users to notify (subscribers to any received part)
1054 notify_users = set()
1056 # Cache of subscribers per part, to avoid repeated queries for the same part
1057 part_subscribers_cache: dict[int, list] = {}
1059 convert_purchase_price = get_global_setting('PURCHASEORDER_CONVERT_CURRENCY')
1060 default_currency = currency_code_default()
1062 # Prefetch line item objects for DB efficiency
1063 line_items_ids = [item['line_item'].pk for item in items]
1065 # Cache the custom status options for the StockItem model
1066 custom_stock_status_values = stock.models.StockItem.STATUS_CLASS.custom_values()
1068 # Lock the line item rows, so that concurrent receipts against the same
1069 # lines cannot both read the same 'received' value (lost update)
1070 line_items = (
1071 PurchaseOrderLineItem.objects
1072 .select_for_update()
1073 .filter(pk__in=line_items_ids)
1074 .prefetch_related('part', 'part__part', 'order')
1075 )
1077 # Map order line items to their corresponding stock items
1078 line_item_map = {line.pk: line for line in line_items}
1080 # Before we continue, validate that each line item is valid
1081 # We validate this here because it is far more efficient,
1082 # after we have fetched *all* line items in a single DB query
1083 for line_item in line_item_map.values():
1084 if line_item.order != self:
1085 raise ValidationError({_('Line item does not match purchase order')})
1087 if not line_item.part or not line_item.part.part:
1088 raise ValidationError({_('Line item is missing a linked part')})
1090 for item in items:
1091 # Extract required information
1092 line_item_id = item['line_item'].pk
1094 line = line_item_map[line_item_id]
1096 quantity = item['quantity']
1097 barcode = item.get('barcode', '')
1099 try:
1100 if quantity < 0:
1101 raise ValidationError({
1102 'quantity': _('Quantity must be a positive number')
1103 })
1104 quantity = InvenTree.helpers.clean_decimal(quantity)
1105 except TypeError:
1106 raise ValidationError({'quantity': _('Invalid quantity provided')})
1108 supplier_part = line.part
1110 if not supplier_part:
1111 logger.warning(
1112 'Line item %s is missing a linked supplier part', line.pk
1113 )
1114 continue
1116 base_part = supplier_part.part
1118 # Update the line item quantity
1119 line.received += quantity
1120 line_items_to_update.append(line)
1122 # Track subscribers to this part, to notify them later
1123 # (cache the result per-part, as multiple lines may reference the same part)
1124 if base_part.pk not in part_subscribers_cache:
1125 part_subscribers_cache[base_part.pk] = base_part.get_subscribers()
1127 notify_users.update(part_subscribers_cache[base_part.pk])
1129 # Extract optional serial numbers
1130 serials = item.get('serials', None)
1132 if serials and type(serials) is list and len(serials) > 0:
1133 serialize = True
1134 else:
1135 serialize = False
1136 serials = [None]
1138 if base_part.virtual:
1139 # Virtual parts are not received into stock, so skip the rest of the loop
1141 if serialize:
1142 raise ValidationError(
1143 _('Serial numbers cannot be assigned to virtual parts')
1144 )
1146 continue
1148 stock_location = item.get('location', location) or line.get_destination()
1150 # Calculate the received quantity in base part units
1151 stock_quantity = supplier_part.base_quantity(quantity)
1153 # Calculate unit purchase price (in base units)
1154 if line.purchase_price:
1155 purchase_price = line.purchase_price / supplier_part.base_quantity(1)
1157 if convert_purchase_price:
1158 purchase_price = convert_money(purchase_price, default_currency)
1159 else:
1160 purchase_price = None
1162 # Construct dataset for creating a new StockItem instances
1163 stock_data = {
1164 'part': supplier_part.part,
1165 'supplier_part': supplier_part,
1166 'purchase_order': self,
1167 'purchase_price': purchase_price,
1168 'location': stock_location,
1169 'quantity': 1 if serialize else stock_quantity,
1170 'batch': item.get('batch_code', ''),
1171 'expiry_date': item.get('expiry_date', None),
1172 'notes': item.get('note', '') or item.get('notes', ''),
1173 'packaging': item.get('packaging') or supplier_part.packaging,
1174 }
1176 # Extract the "status" field
1177 status = item.get('status', StockStatus.OK.value)
1179 # Check linked build order
1180 # This is for receiving against an *external* build order
1181 if build_order := line.build_order:
1182 if not build_order.external:
1183 raise ValidationError(
1184 'Cannot receive items against an internal build order'
1185 )
1187 if build_order.part != base_part:
1188 raise ValidationError(
1189 'Cannot receive items against a build order for a different part'
1190 )
1192 if not stock_location and build_order.destination:
1193 # Override with the build order destination (if not specified)
1194 stock_data['location'] = stock_location = build_order.destination
1196 if build_order.active:
1197 # An 'active' build order marks the items as "in production"
1198 stock_data['build'] = build_order
1199 stock_data['is_building'] = True
1200 elif build_order.status == BuildStatus.COMPLETE:
1201 # A 'completed' build order marks the items as "completed"
1202 stock_data['build'] = build_order
1203 stock_data['is_building'] = False
1205 # Increase the 'completed' quantity for the build order
1206 # Increment at the database level to prevent lost updates
1207 build_order.completed = F('completed') + stock_quantity
1208 build_order.save(update_fields=['completed'])
1209 build_order.refresh_from_db(fields=['completed'])
1210 elif build_order.status == BuildStatus.CANCELLED:
1211 # A 'cancelled' build order is ignored
1212 pass
1213 else:
1214 # Un-handled state - raise an error
1215 raise ValidationError(
1216 "Cannot receive items against a build order in state '{build_order.status}'"
1217 )
1219 # Now, create the new stock items
1220 if serialize:
1221 new_items = stock.models.StockItem._create_serial_numbers(
1222 serials=serials, **stock_data
1223 )
1225 for new_item in new_items:
1226 new_item.set_status(
1227 status, custom_values=custom_stock_status_values
1228 )
1229 # run validation for serialized items plugin.validate_batch_code
1230 new_item.validate_batch_code()
1231 # run validation for serialized items plugin.validate_model_instance
1232 new_item.run_plugin_validation()
1233 stock_items.append(new_item)
1235 else:
1236 new_item = stock.models.StockItem(
1237 **stock_data,
1238 serial='',
1239 tree_id=stock.models.StockItem.getNextTreeID(),
1240 parent=None,
1241 level=0,
1242 lft=1,
1243 rght=2,
1244 )
1246 new_item.set_status(status, custom_values=custom_stock_status_values)
1248 if barcode:
1249 new_item.assign_barcode(barcode_data=barcode, save=False)
1251 bulk_create_items.append(new_item)
1253 # Bulk create new stock items
1254 if len(bulk_create_items) > 0:
1255 # bulk_create() bypasses save()/clean() methods, so manual validation is required for each item
1256 for item in bulk_create_items:
1257 # run validation for items plugin.validate_batch_code
1258 item.validate_batch_code()
1259 # run validation for items plugin.validate_model_instance
1260 item.run_plugin_validation()
1262 stock.models.StockItem.objects.bulk_create(
1263 bulk_create_items, batch_size=250
1264 )
1266 # Fetch them back again
1267 tree_ids = [item.tree_id for item in bulk_create_items]
1269 created_items = stock.models.StockItem.objects.filter(
1270 tree_id__in=tree_ids, level=0, lft=1, rght=2, purchase_order=self
1271 ).prefetch_related('location')
1273 stock_items.extend(created_items)
1275 # Generate a new tracking entry for each stock item
1276 for item in stock_items:
1277 tracking_entries.append(
1278 item.add_tracking_entry(
1279 StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
1280 user,
1281 deltas={
1282 'location': item.location.pk if item.location else None,
1283 'purchaseorder': self.pk,
1284 'quantity': float(item.quantity),
1285 },
1286 commit=False,
1287 )
1288 )
1290 # Bulk create new tracking entries for each item
1291 stock.models.StockItemTracking.objects.bulk_create(
1292 tracking_entries, batch_size=250
1293 )
1295 # Update received quantity for each line item
1296 PurchaseOrderLineItem.objects.bulk_update(line_items_to_update, ['received'])
1298 # Trigger an event for any interested plugins
1299 trigger_event(
1300 PurchaseOrderEvents.ITEM_RECEIVED,
1301 order_id=self.pk,
1302 item_ids=[item.pk for item in stock_items],
1303 )
1305 # Check to auto-complete the PurchaseOrder
1306 if (
1307 get_global_setting('PURCHASEORDER_AUTO_COMPLETE', True)
1308 and self.pending_line_count == 0
1309 ):
1310 self.received_by = user
1311 self.complete_order()
1313 # Send notification
1314 notify_responsible(
1315 self,
1316 PurchaseOrder,
1317 exclude=user,
1318 content=InvenTreeNotificationBodies.ItemsReceived,
1319 extra_users=notify_users,
1320 )
1322 # Return a list of the created stock items
1323 return stock.models.StockItem.objects.filter(
1324 pk__in=[item.pk for item in stock_items]
1325 )
1327 @transaction.atomic
1328 def receive_line_item(
1329 self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs
1330 ):
1331 """Receive a line item (or partial line item) against this PurchaseOrder.
1333 Arguments:
1334 line: The PurchaseOrderLineItem to receive against
1335 location: The StockLocation to receive the item into
1336 quantity: The quantity to receive
1337 user: The User performing the action
1338 status: The StockStatus to assign to the item (default: StockStatus.OK)
1340 Keyword Arguments:
1341 batch_code: Optional batch code for the new StockItem
1342 serials: Optional list of serial numbers to assign to the new StockItem(s)
1343 notes: Optional notes field for the StockItem
1344 packaging: Optional packaging field for the StockItem
1345 barcode: Optional barcode field for the StockItem
1346 notify: If true, notify users of received items
1348 Raises:
1349 ValidationError: If the quantity is negative or otherwise invalid
1350 ValidationError: If the order is not in the 'PLACED' state
1351 """
1352 self.receive_line_items(
1353 location,
1354 [
1355 {
1356 'line_item': line,
1357 'quantity': quantity,
1358 'location': location,
1359 'status': status,
1360 **kwargs,
1361 }
1362 ],
1363 user,
1364 )
1367STOCK_SORT_CHOICES = stock.models.STOCK_SORT_CHOICES
1368STOCK_SORT_DEFAULT = stock.models.STOCK_SORT_DEFAULT
1370SERIALIZED_STOCK_CHOICES = [
1371 ('any', _('Allow any stock (serialized or unserialized)')),
1372 ('serialized', _('Serialized stock only')),
1373 ('unserialized', _('Unserialized stock only')),
1374]
1376SERIALIZED_STOCK_DEFAULT = 'any'
1379class SalesOrder(TotalPriceMixin, Order):
1380 """A SalesOrder represents a list of goods shipped outwards to a customer."""
1382 REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
1383 REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE'
1384 STATUS_CLASS = SalesOrderStatus
1385 UNLOCK_SETTING = 'SALESORDER_EDIT_COMPLETED_ORDERS'
1387 class Meta:
1388 """Model meta options."""
1390 verbose_name = _('Sales Order')
1392 def clean_line_item(self, line):
1393 """Clean a line item for this SalesOrder."""
1394 super().clean_line_item(line)
1395 line.shipped = 0
1397 def report_context(self) -> SalesOrderReportContext:
1398 """Generate report context data for this SalesOrder."""
1399 return_ctx = super().report_context()
1401 return_ctx.update({'customer': self.customer}) # ty:ignore[invalid-key]
1402 return return_ctx
1404 def get_absolute_url(self) -> str:
1405 """Get the 'web' URL for this order."""
1406 return pui_url(f'/sales/sales-order/{self.pk}')
1408 @staticmethod
1409 def get_api_url() -> str:
1410 """Return the API URL associated with the SalesOrder model."""
1411 return reverse('api-so-list')
1413 @classmethod
1414 def get_status_class(cls):
1415 """Return the SalesOrderStatus class."""
1416 return SalesOrderStatusGroups
1418 @classmethod
1419 def api_defaults(cls, request=None) -> dict:
1420 """Return default values for this model when issuing an API OPTIONS request."""
1421 defaults = {'reference': order.validators.generate_next_sales_order_reference()}
1423 return defaults
1425 @classmethod
1426 def barcode_model_type_code(cls) -> str:
1427 """Return the associated barcode model type code for this model."""
1428 return 'SO'
1430 def subscribed_users(self) -> list[User]:
1431 """Return a list of users subscribed to this SalesOrder.
1433 By this, we mean users to are interested in any of the parts associated with this order.
1434 """
1435 subscribed_users = set()
1437 for line in self.lines.all():
1438 if line.part:
1439 # Add the part to the list of subscribed users
1440 for user in line.part.get_subscribers():
1441 subscribed_users.add(user)
1443 return list(subscribed_users)
1445 def __str__(self):
1446 """Render a string representation of this SalesOrder."""
1447 return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}'
1449 reference = models.CharField(
1450 unique=True,
1451 max_length=64,
1452 blank=False,
1453 verbose_name=_('Reference'),
1454 help_text=_('Order reference'),
1455 default=order.validators.generate_next_sales_order_reference,
1456 validators=[order.validators.validate_sales_order_reference],
1457 )
1459 customer = models.ForeignKey(
1460 Company,
1461 on_delete=models.SET_NULL,
1462 null=True,
1463 limit_choices_to={'is_customer': True},
1464 related_name='return_orders',
1465 verbose_name=_('Customer'),
1466 help_text=_('Company to which the items are being sold'),
1467 )
1469 @property
1470 def company(self):
1471 """Accessor helper for Order base."""
1472 return self.customer
1474 status = InvenTreeCustomStatusModelField(
1475 default=SalesOrderStatus.PENDING.value,
1476 choices=SalesOrderStatus.items(),
1477 status_class=SalesOrderStatus,
1478 verbose_name=_('Status'),
1479 help_text=_('Sales order status'),
1480 )
1482 customer_reference = models.CharField(
1483 max_length=64,
1484 blank=True,
1485 verbose_name=_('Customer Reference '),
1486 help_text=_('Customer order reference code'),
1487 )
1489 shipment_date = models.DateField(
1490 blank=True, null=True, verbose_name=_('Shipment Date')
1491 )
1493 shipped_by = models.ForeignKey(
1494 User,
1495 on_delete=models.SET_NULL,
1496 blank=True,
1497 null=True,
1498 related_name='+',
1499 verbose_name=_('shipped by'),
1500 )
1502 @property
1503 def is_pending(self) -> bool:
1504 """Return True if this order is 'pending'."""
1505 return self.status == SalesOrderStatus.PENDING
1507 @property
1508 def is_open(self) -> bool:
1509 """Return True if this order is 'open' (either 'pending' or 'in_progress')."""
1510 return self.status in SalesOrderStatusGroups.OPEN
1512 @property
1513 def stock_allocations(self) -> QuerySet:
1514 """Return a queryset containing all allocations for this order."""
1515 return SalesOrderAllocation.objects.filter(
1516 line__in=[line.pk for line in self.lines.all()]
1517 )
1519 def is_fully_allocated(self) -> bool:
1520 """Return True if all line items are fully allocated."""
1521 return all(line.is_fully_allocated() for line in self.lines.all())
1523 def is_overallocated(self) -> bool:
1524 """Return true if any lines in the order are over-allocated."""
1525 return any(line.is_overallocated() for line in self.lines.all())
1527 @transaction.atomic
1528 def auto_allocate_stock(
1529 self,
1530 location: Optional[stock.models.StockLocation] = None,
1531 exclude_location: Optional[stock.models.StockLocation] = None,
1532 shipment: Optional['SalesOrderShipment'] = None,
1533 line_ids: Optional[list] = None,
1534 **kwargs,
1535 ):
1536 """Automatically allocate stock items against this SalesOrder.
1538 For each unallocated line item, finds available stock for
1539 the line's part, filtered and sorted according to the supplied kwargs, then
1540 creates SalesOrderAllocation records in bulk.
1542 Arguments:
1543 location: If provided, only consider stock within this location tree.
1544 exclude_location: If provided, exclude stock within this location tree.
1545 shipment: Optional shipment to assign allocations to.
1546 line_ids: If provided, only allocate against these specific line item PKs.
1548 Kwargs:
1549 interchangeable (bool): If True (default), consume stock from multiple
1550 items/locations to satisfy a line. If False, only allocate when a
1551 single item can cover the full remaining quantity.
1552 """
1553 stock_sort_by = kwargs.get('stock_sort_by', STOCK_SORT_DEFAULT)
1554 interchangeable = kwargs.get('interchangeable', True)
1555 serialized_stock = kwargs.get('serialized_stock', SERIALIZED_STOCK_DEFAULT)
1557 new_allocations = []
1559 lines = self.lines.all()
1560 if line_ids:
1561 lines = lines.filter(pk__in=line_ids)
1563 for line_item in lines:
1564 if not line_item.part:
1565 continue
1567 if line_item.part.virtual:
1568 continue
1570 unallocated = line_item.quantity - line_item.allocated_quantity()
1572 if unallocated <= 0:
1573 continue
1575 available_stock = stock.models.StockItem.objects.filter(
1576 stock.models.StockItem.IN_STOCK_FILTER, part=line_item.part
1577 )
1579 if location:
1580 sublocations = location.get_descendants(include_self=True)
1581 available_stock = available_stock.filter(
1582 location__in=list(sublocations)
1583 )
1585 if exclude_location:
1586 sublocations = exclude_location.get_descendants(include_self=True)
1587 available_stock = available_stock.exclude(
1588 location__in=list(sublocations)
1589 )
1591 if serialized_stock == 'serialized':
1592 available_stock = available_stock.filter(
1593 serial__isnull=False, quantity=1
1594 ).exclude(serial='')
1595 elif serialized_stock == 'unserialized':
1596 available_stock = available_stock.filter(
1597 Q(serial__isnull=True) | Q(serial='')
1598 )
1600 # Handle NULL expiry_date last when sorting by expiry.
1601 if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST:
1602 available_stock = available_stock.order_by(
1603 F('expiry_date').asc(nulls_last=True)
1604 )
1605 else:
1606 available_stock = available_stock.order_by(stock_sort_by)
1608 stock_count = available_stock.count()
1610 if stock_count == 0:
1611 continue
1613 if not interchangeable and stock_count > 1:
1614 # Only allocate when a single item can fully cover the requirement.
1615 single = next(
1616 (
1617 s
1618 for s in available_stock
1619 if s.unallocated_quantity() >= unallocated
1620 ),
1621 None,
1622 )
1623 if single is None:
1624 continue
1625 available_stock = [single]
1627 for stock_item in available_stock:
1628 available_qty = stock_item.unallocated_quantity()
1630 if available_qty <= 0:
1631 continue
1633 quantity = min(unallocated, available_qty)
1635 new_allocations.append(
1636 SalesOrderAllocation(
1637 line=line_item,
1638 item=stock_item,
1639 quantity=quantity,
1640 shipment=shipment,
1641 )
1642 )
1644 unallocated -= quantity
1646 if unallocated <= 0:
1647 break
1649 SalesOrderAllocation.objects.bulk_create(new_allocations, batch_size=250)
1651 def is_completed(self) -> bool:
1652 """Check if this order is "shipped" (all line items delivered).
1654 Note: Any "virtual" parts are ignored in this calculation.
1655 """
1656 lines = self.lines.all().filter(part__virtual=False)
1658 return all(line.is_completed() for line in lines)
1660 def can_complete(
1661 self, raise_error: bool = False, allow_incomplete_lines: bool = False
1662 ) -> bool:
1663 """Test if this SalesOrder can be completed.
1665 Arguments:
1666 raise_error: If True, raise ValidationError if the order cannot be completed
1667 allow_incomplete_lines: If True, allow incomplete line items when completing the order
1669 Raises:
1670 ValidationError: If the order cannot be completed, and raise_error is True
1671 """
1672 try:
1673 if self.status == SalesOrderStatus.COMPLETE.value:
1674 raise ValidationError(_('Order is already complete'))
1676 if self.status == SalesOrderStatus.CANCELLED.value:
1677 raise ValidationError(_('Order is already cancelled'))
1679 # Only an open order can be marked as shipped
1680 if self.is_open and not self.is_completed:
1681 raise ValidationError(_('Only an open order can be marked as complete'))
1683 if self.pending_shipment_count > 0:
1684 raise ValidationError(
1685 _('Order cannot be completed as there are incomplete shipments')
1686 )
1688 if self.pending_allocation_count > 0:
1689 raise ValidationError(
1690 _('Order cannot be completed as there are incomplete allocations')
1691 )
1693 if not allow_incomplete_lines:
1694 pending_lines = self.pending_line_items().exclude(part__virtual=True)
1696 if pending_lines.count() > 0:
1697 raise ValidationError(
1698 _(
1699 'Order cannot be completed as there are incomplete line items'
1700 )
1701 )
1703 except ValidationError as e:
1704 if raise_error:
1705 raise e
1706 else:
1707 return False
1709 return True
1711 # region state changes
1712 def place_order(self):
1713 """Deprecated version of 'issue_order'."""
1714 self.issue_order()
1716 @property
1717 def can_issue(self) -> bool:
1718 """Return True if this order can be issued."""
1719 return self.status in [
1720 SalesOrderStatus.PENDING.value,
1721 SalesOrderStatus.ON_HOLD.value,
1722 ]
1724 def _action_place(self, *args, **kwargs):
1725 """Change this order from 'PENDING' to 'IN_PROGRESS'."""
1726 if self.can_issue:
1727 self.status = SalesOrderStatus.IN_PROGRESS.value
1728 self.issue_date = InvenTree.helpers.current_date()
1729 self.save()
1731 trigger_event(SalesOrderEvents.ISSUED, id=self.pk)
1733 # Notify users that the order has been placed
1734 notify_responsible(
1735 self,
1736 SalesOrder,
1737 exclude=self.created_by,
1738 content=InvenTreeNotificationBodies.NewOrder,
1739 extra_users=self.subscribed_users(),
1740 )
1742 @property
1743 def can_hold(self) -> bool:
1744 """Return True if this order can be placed on hold."""
1745 return self.status in [
1746 SalesOrderStatus.PENDING.value,
1747 SalesOrderStatus.IN_PROGRESS.value,
1748 ]
1750 def _action_hold(self, *args, **kwargs):
1751 """Mark this sales order as 'on hold'."""
1752 if self.can_hold:
1753 self.status = SalesOrderStatus.ON_HOLD.value
1754 self.save()
1756 trigger_event(SalesOrderEvents.HOLD, id=self.pk)
1758 @transaction.atomic
1759 def _action_complete(self, *args, **kwargs):
1760 """Mark this order as "complete."""
1761 user = kwargs.pop('user', None)
1763 if not self.can_complete(**kwargs):
1764 return False
1766 bypass_shipped = InvenTree.helpers.str2bool(
1767 get_global_setting('SALESORDER_SHIP_COMPLETE')
1768 )
1770 # Update line items
1771 for line in self.lines.all():
1772 # Mark any "virtual" parts as shipped at this point
1773 if line.part and line.part.virtual and line.shipped != line.quantity:
1774 line.shipped = line.quantity
1775 line.save()
1777 if line.part:
1778 line.part.schedule_pricing_update(create=True)
1780 if bypass_shipped or self.status == SalesOrderStatus.SHIPPED:
1781 self.status = SalesOrderStatus.COMPLETE.value
1782 else:
1783 self.status = SalesOrderStatus.SHIPPED.value
1785 if self.shipment_date is None:
1786 self.shipped_by = user
1787 self.shipment_date = InvenTree.helpers.current_date()
1789 self.save()
1791 trigger_event(SalesOrderEvents.COMPLETED, id=self.pk)
1793 return True
1795 @property
1796 def can_cancel(self) -> bool:
1797 """Return True if this order can be cancelled."""
1798 return self.is_open
1800 def _action_cancel(self, *args, **kwargs):
1801 """Cancel this order (only if it is "open").
1803 Executes:
1804 - Mark the order as 'cancelled'
1805 - Delete any StockItems which have been allocated
1806 """
1807 if not self.can_cancel:
1808 return False
1810 self.status = SalesOrderStatus.CANCELLED.value
1811 self.save()
1813 for line in self.lines.all():
1814 for allocation in line.allocations.all():
1815 allocation.delete()
1817 trigger_event(SalesOrderEvents.CANCELLED, id=self.pk)
1819 # Notify users that the order has been canceled
1820 notify_responsible(
1821 self,
1822 SalesOrder,
1823 exclude=self.created_by,
1824 content=InvenTreeNotificationBodies.OrderCanceled,
1825 extra_users=self.subscribed_users(),
1826 )
1828 return True
1830 @transaction.atomic
1831 def issue_order(self):
1832 """Attempt to transition to IN_PROGRESS status."""
1833 return self.handle_transition(
1834 self.status, SalesOrderStatus.IN_PROGRESS.value, self, self._action_place
1835 )
1837 @transaction.atomic
1838 def ship_order(self, user, **kwargs):
1839 """Attempt to transition to SHIPPED status."""
1840 return self.handle_transition(
1841 self.status,
1842 SalesOrderStatus.SHIPPED.value,
1843 self,
1844 self._action_complete,
1845 user=user,
1846 **kwargs,
1847 )
1849 @transaction.atomic
1850 def complete_order(self, user, **kwargs):
1851 """Attempt to transition to COMPLETED status."""
1852 return self.handle_transition(
1853 self.status,
1854 SalesOrderStatus.COMPLETE.value,
1855 self,
1856 self._action_complete,
1857 user=user,
1858 **kwargs,
1859 )
1861 @transaction.atomic
1862 def hold_order(self):
1863 """Attempt to transition to ON_HOLD status."""
1864 return self.handle_transition(
1865 self.status, SalesOrderStatus.ON_HOLD.value, self, self._action_hold
1866 )
1868 @transaction.atomic
1869 def cancel_order(self):
1870 """Attempt to transition to CANCELLED status."""
1871 return self.handle_transition(
1872 self.status, SalesOrderStatus.CANCELLED.value, self, self._action_cancel
1873 )
1875 # endregion
1877 @property
1878 def line_count(self) -> int:
1879 """Return the total number of lines associated with this order."""
1880 return self.lines.count()
1882 def completed_line_items(self) -> QuerySet:
1883 """Return a queryset of the completed line items for this order."""
1884 return self.lines.filter(shipped__gte=F('quantity'))
1886 def pending_line_items(self) -> QuerySet:
1887 """Return a queryset of the pending line items for this order.
1889 Note: We exclude "virtual" parts here, as they do not get allocated
1890 """
1891 return self.lines.filter(shipped__lt=F('quantity')).exclude(part__virtual=True)
1893 @property
1894 def completed_line_count(self) -> int:
1895 """Return the number of completed lines for this order."""
1896 return self.completed_line_items().count()
1898 @property
1899 def pending_line_count(self) -> int:
1900 """Return the number of pending (incomplete) lines associated with this order."""
1901 return self.pending_line_items().count()
1903 def completed_shipments(self) -> QuerySet:
1904 """Return a queryset of the completed shipments for this order."""
1905 return self.shipments.exclude(shipment_date=None)
1907 def pending_shipments(self) -> QuerySet:
1908 """Return a queryset of the pending shipments for this order."""
1909 return self.shipments.filter(shipment_date=None)
1911 def allocations(self) -> QuerySet:
1912 """Return a queryset of all allocations for this order."""
1913 return SalesOrderAllocation.objects.filter(line__order=self)
1915 def pending_allocations(self) -> QuerySet:
1916 """Return a queryset of any pending allocations for this order.
1918 Allocations are pending if:
1920 a) They are not associated with a SalesOrderShipment
1921 b) The linked SalesOrderShipment has not been shipped
1922 """
1923 Q1 = Q(shipment=None)
1924 Q2 = Q(shipment__shipment_date=None)
1926 return self.allocations().filter(Q1 | Q2).distinct()
1928 @property
1929 def shipment_count(self) -> int:
1930 """Return the total number of shipments associated with this order."""
1931 return self.shipments.count()
1933 @property
1934 def completed_shipment_count(self) -> int:
1935 """Return the number of completed shipments associated with this order."""
1936 return self.completed_shipments().count()
1938 @property
1939 def pending_shipment_count(self) -> int:
1940 """Return the number of pending shipments associated with this order."""
1941 return self.pending_shipments().count()
1943 @property
1944 def pending_allocation_count(self) -> int:
1945 """Return the number of pending (non-shipped) allocations."""
1946 return self.pending_allocations().count()
1949@receiver(post_save, sender=SalesOrder, dispatch_uid='sales_order_post_save')
1950def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
1951 """Callback function to be executed after a SalesOrder is saved.
1953 - If the SALESORDER_DEFAULT_SHIPMENT setting is enabled, create a default shipment
1954 - Ignore if the database is not ready for access
1955 - Ignore if data import is active
1956 """
1957 if (
1958 not InvenTree.ready.canAppAccessDatabase(allow_test=True)
1959 or InvenTree.ready.isImportingData()
1960 ):
1961 return
1963 if created:
1964 # A new SalesOrder has just been created
1966 if get_global_setting('SALESORDER_DEFAULT_SHIPMENT'):
1967 # Create default shipment
1968 SalesOrderShipment.objects.create(order=instance, reference='1')
1971class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
1972 """Abstract model for an order line item.
1974 Attributes:
1975 quantity: Number of items
1976 line: The line number for this item (optional)
1977 line_int: An integer line number for this item (optional - used for natural sorting)
1978 reference: Reference text (e.g. customer reference) for this line item
1979 project_code: Project code associated with this line item (optional)
1980 note: Annotation for the item
1981 target_date: An (optional) date for expected shipment of this line item.
1982 """
1984 class Meta:
1985 """Metaclass options. Abstract ensures no database table is created."""
1987 abstract = True
1989 def save(self, *args, **kwargs):
1990 """Custom save method for the OrderLineItem model.
1992 Calls save method on the linked order
1993 """
1994 if self.order and self.order.check_locked():
1995 raise ValidationError({
1996 'non_field_errors': _('The order is locked and cannot be modified')
1997 })
1999 update_order = kwargs.pop('update_order', True)
2001 # Update the integer representation of the line number (for natural sorting)
2002 if self.line:
2003 try:
2004 self.line_int = int(self.line)
2005 except (TypeError, ValueError):
2006 self.line_int = 0
2007 else:
2008 self.line_int = 0
2010 super().save(*args, **kwargs)
2011 if update_order and self.order:
2012 self.order.save()
2014 def delete(self, *args, **kwargs):
2015 """Custom delete method for the OrderLineItem model.
2017 Calls save method on the linked order
2018 """
2019 if self.order and self.order.check_locked():
2020 raise ValidationError({
2021 'non_field_errors': _('The order is locked and cannot be modified')
2022 })
2024 super().delete(*args, **kwargs)
2025 self.order.save()
2027 quantity = RoundingDecimalField(
2028 verbose_name=_('Quantity'),
2029 help_text=_('Item quantity'),
2030 default=1,
2031 max_digits=15,
2032 decimal_places=5,
2033 validators=[MinValueValidator(0)],
2034 )
2036 @property
2037 def total_line_price(self):
2038 """Return the total price for this line item."""
2039 if self.price:
2040 return self.quantity * self.price
2042 line = models.CharField(
2043 max_length=20,
2044 blank=True,
2045 default='',
2046 null=False,
2047 verbose_name=_('Line Number'),
2048 help_text=_('Line number for this item (optional)'),
2049 )
2051 line_int = models.IntegerField(default=0, blank=False, null=False)
2053 reference = models.CharField(
2054 max_length=100,
2055 blank=True,
2056 verbose_name=_('Reference'),
2057 help_text=_('Line item reference'),
2058 )
2060 notes = models.CharField(
2061 max_length=500,
2062 blank=True,
2063 verbose_name=_('Notes'),
2064 help_text=_('Line item notes'),
2065 )
2067 link = InvenTreeURLField(
2068 blank=True,
2069 verbose_name=_('Link'),
2070 help_text=_('Link to external page'),
2071 max_length=2000,
2072 )
2074 target_date = models.DateField(
2075 blank=True,
2076 null=True,
2077 verbose_name=_('Target Date'),
2078 help_text=_(
2079 'Target date for this line item (leave blank to use the target date from the order)'
2080 ),
2081 )
2083 project_code = models.ForeignKey(
2084 common_models.ProjectCode,
2085 on_delete=models.SET_NULL,
2086 blank=True,
2087 null=True,
2088 verbose_name=_('Project Code'),
2089 help_text=_('Select project code for this order'),
2090 )
2093class OrderExtraLine(OrderLineItem):
2094 """Abstract Model for a single ExtraLine in a Order.
2096 Attributes:
2097 price: The unit sale price for this OrderLineItem
2098 """
2100 class Meta:
2101 """Metaclass options. Abstract ensures no database table is created."""
2103 abstract = True
2105 description = models.CharField(
2106 max_length=250,
2107 blank=True,
2108 verbose_name=_('Description'),
2109 help_text=_('Line item description (optional)'),
2110 )
2112 context = models.JSONField(
2113 blank=True,
2114 null=True,
2115 verbose_name=_('Context'),
2116 help_text=_('Additional context for this line'),
2117 )
2119 price = InvenTreeModelMoneyField(
2120 max_digits=19,
2121 decimal_places=6,
2122 null=True,
2123 blank=True,
2124 allow_negative=True,
2125 verbose_name=_('Price'),
2126 help_text=_('Unit price'),
2127 )
2130class PurchaseOrderLineItem(OrderLineItem):
2131 """Model for a purchase order line item.
2133 Attributes:
2134 order: Reference to a PurchaseOrder object
2135 part: Reference to a SupplierPart object
2136 received: Number of items received
2137 purchase_price: Unit purchase price for this line item
2138 build_order: Link to an external BuildOrder to be fulfilled by this line item
2139 destination: Destination for received items
2140 """
2142 class Meta:
2143 """Model meta options."""
2145 verbose_name = _('Purchase Order Line Item')
2147 @classmethod
2148 def get_overdue_filter(cls):
2149 """Filter for determining if a particular PurchaseOrderLineItem is overdue."""
2150 return (
2151 Q(received__lt=F('quantity'))
2152 & ~Q(target_date=None)
2153 & Q(target_date__lt=InvenTree.helpers.current_date())
2154 )
2156 @staticmethod
2157 def get_api_url() -> str:
2158 """Return the API URL associated with the PurchaseOrderLineItem model."""
2159 return reverse('api-po-line-list')
2161 def clean(self) -> None:
2162 """Custom clean method for the PurchaseOrderLineItem model.
2164 Raises:
2165 ValidationError: If the SupplierPart does not match the PurchaseOrder supplier
2166 ValidationError: If the linked BuildOrder is not marked as external
2167 ValidationError: If the linked BuildOrder part does not match the line item part
2168 """
2169 super().clean()
2171 if self.order.supplier and self.part:
2172 # Supplier part *must* point to the same supplier!
2173 if self.part.supplier != self.order.supplier:
2174 raise ValidationError({'part': _('Supplier part must match supplier')})
2176 # Link to the base part
2177 part = self.part.part
2179 if self.build_order:
2180 if not self.build_order.external:
2181 raise ValidationError({
2182 'build_order': _('Build order must be marked as external')
2183 })
2185 if part:
2186 if not part.assembly:
2187 raise ValidationError({
2188 'build_order': _(
2189 'Build orders can only be linked to assembly parts'
2190 )
2191 })
2193 if self.build_order.part != self.part.part:
2194 raise ValidationError({
2195 'build_order': _('Build order part must match line item part')
2196 })
2198 # Extra checks for external builds
2199 if part and part.assembly and get_global_setting('BUILDORDER_EXTERNAL_BUILDS'):
2200 if not self.build_order and get_global_setting(
2201 'BUILDORDER_EXTERNAL_REQUIRED'
2202 ):
2203 raise ValidationError({
2204 'build_order': _(
2205 'An external build order is required for assembly parts'
2206 )
2207 })
2209 def __str__(self):
2210 """Render a string representation of a PurchaseOrderLineItem instance."""
2211 return '{n} x {part} - {po}'.format(
2212 n=decimal2string(self.quantity),
2213 part=self.part.SKU if self.part else 'unknown part',
2214 po=self.order,
2215 )
2217 order = models.ForeignKey(
2218 PurchaseOrder,
2219 on_delete=models.CASCADE,
2220 related_name='lines',
2221 verbose_name=_('Order'),
2222 help_text=_('Purchase Order'),
2223 )
2225 def get_base_part(self):
2226 """Return the base part.Part object for the line item.
2228 Note: Returns None if the SupplierPart is not set!
2229 """
2230 if self.part is None:
2231 return None
2232 return self.part.part
2234 part = models.ForeignKey(
2235 SupplierPart,
2236 on_delete=models.SET_NULL,
2237 blank=False,
2238 null=True,
2239 related_name='purchase_order_line_items',
2240 verbose_name=_('Part'),
2241 help_text=_('Supplier part'),
2242 )
2244 received = models.DecimalField(
2245 decimal_places=5,
2246 max_digits=15,
2247 default=0,
2248 verbose_name=_('Received'),
2249 help_text=_('Number of items received'),
2250 )
2252 purchase_price = InvenTreeModelMoneyField(
2253 max_digits=19,
2254 decimal_places=6,
2255 null=True,
2256 blank=True,
2257 verbose_name=_('Purchase Price'),
2258 help_text=_('Unit purchase price'),
2259 )
2261 @property
2262 def price(self):
2263 """Return the 'purchase_price' field as 'price'."""
2264 return self.purchase_price
2266 build_order = models.ForeignKey(
2267 'build.Build',
2268 on_delete=models.SET_NULL,
2269 blank=True,
2270 related_name='external_line_items',
2271 limit_choices_to={'external': True},
2272 null=True,
2273 verbose_name=_('Build Order'),
2274 help_text=_('External Build Order to be fulfilled by this line item'),
2275 )
2277 destination = TreeForeignKey(
2278 'stock.StockLocation',
2279 on_delete=models.SET_NULL,
2280 verbose_name=_('Destination'),
2281 related_name='po_lines',
2282 blank=True,
2283 null=True,
2284 help_text=_('Destination for received items'),
2285 )
2287 def get_destination(self):
2288 """Show where the line item is or should be placed.
2290 1. If a destination is specified against this line item, return that.
2291 2. If a destination is specified against the PurchaseOrderPart, return that.
2292 3. If a default location is specified against the linked Part, return that.
2293 """
2294 if self.destination:
2295 return self.destination
2297 if self.order.destination:
2298 return self.order.destination
2300 if self.part and self.part.part and self.part.part.default_location:
2301 return self.part.part.default_location
2303 def remaining(self):
2304 """Calculate the number of items remaining to be received."""
2305 r = self.quantity - self.received
2306 return max(r, 0)
2308 def is_completed(self) -> bool:
2309 """Determine if this line item has been fully received."""
2310 return self.received >= self.quantity
2312 def update_pricing(self) -> None:
2313 """Update pricing information based on the supplier part data."""
2314 if self.part:
2315 price = self.part.get_price(
2316 self.quantity, currency=self.purchase_price_currency
2317 )
2319 if price is None or self.quantity == 0:
2320 return
2322 self.purchase_price = Decimal(price) / Decimal(self.quantity)
2323 self.save()
2326class PurchaseOrderExtraLine(OrderExtraLine):
2327 """Model for a single ExtraLine in a PurchaseOrder.
2329 Attributes:
2330 order: Link to the PurchaseOrder that this line belongs to
2331 """
2333 class Meta:
2334 """Model meta options."""
2336 verbose_name = _('Purchase Order Extra Line')
2338 @staticmethod
2339 def get_api_url() -> str:
2340 """Return the API URL associated with the PurchaseOrderExtraLine model."""
2341 return reverse('api-po-extra-line-list')
2343 order = models.ForeignKey(
2344 PurchaseOrder,
2345 on_delete=models.CASCADE,
2346 related_name='extra_lines',
2347 verbose_name=_('Order'),
2348 help_text=_('Purchase Order'),
2349 )
2352class SalesOrderLineItem(OrderLineItem):
2353 """Model for a single LineItem in a SalesOrder.
2355 Attributes:
2356 order: Link to the SalesOrder that this line item belongs to
2357 part: Link to a Part object (may be null)
2358 sale_price: The unit sale price for this OrderLineItem
2359 shipped: The number of items which have actually shipped against this line item
2360 """
2362 class Meta:
2363 """Model meta options."""
2365 verbose_name = _('Sales Order Line Item')
2367 @classmethod
2368 def get_overdue_filter(cls):
2369 """Filter for determining if a particular SalesOrderLineItem is overdue."""
2370 return (
2371 Q(shipped__lt=F('quantity'))
2372 & ~Q(target_date=None)
2373 & Q(target_date__lt=InvenTree.helpers.current_date())
2374 )
2376 @staticmethod
2377 def get_api_url():
2378 """Return the API URL associated with the SalesOrderLineItem model."""
2379 return reverse('api-so-line-list')
2381 def clean(self) -> None:
2382 """Perform extra validation steps for this SalesOrderLineItem instance.
2384 Raises:
2385 ValidationError: If the linked part is not salable
2386 """
2387 super().clean()
2389 if self.part:
2390 if not self.part.salable:
2391 raise ValidationError({
2392 'part': _('Only salable parts can be assigned to a sales order')
2393 })
2395 order = models.ForeignKey(
2396 SalesOrder,
2397 on_delete=models.CASCADE,
2398 related_name='lines',
2399 verbose_name=_('Order'),
2400 help_text=_('Sales Order'),
2401 )
2403 part = models.ForeignKey(
2404 'part.Part',
2405 on_delete=models.SET_NULL,
2406 related_name='sales_order_line_items',
2407 null=True,
2408 verbose_name=_('Part'),
2409 help_text=_('Part'),
2410 limit_choices_to={'salable': True},
2411 )
2413 sale_price = InvenTreeModelMoneyField(
2414 max_digits=19,
2415 decimal_places=6,
2416 null=True,
2417 blank=True,
2418 verbose_name=_('Sale Price'),
2419 help_text=_('Unit sale price'),
2420 )
2422 @property
2423 def price(self):
2424 """Return the 'sale_price' field as 'price'."""
2425 return self.sale_price
2427 shipped = RoundingDecimalField(
2428 verbose_name=_('Shipped'),
2429 help_text=_('Shipped quantity'),
2430 default=0,
2431 max_digits=15,
2432 decimal_places=5,
2433 validators=[MinValueValidator(0)],
2434 )
2436 def fulfilled_quantity(self):
2437 """Return the total stock quantity fulfilled against this line item."""
2438 if not self.pk:
2439 return 0
2441 query = self.order.stock_items.filter(part=self.part).aggregate(
2442 fulfilled=Coalesce(Sum('quantity'), Decimal(0))
2443 )
2445 return query['fulfilled']
2447 def allocated_quantity(self):
2448 """Return the total stock quantity allocated to this LineItem.
2450 This is a summation of the quantity of each attached StockItem
2451 """
2452 if not self.pk:
2453 return 0
2455 query = self.allocations.aggregate(
2456 allocated=Coalesce(Sum('quantity'), Decimal(0))
2457 )
2459 return query['allocated']
2461 def is_fully_allocated(self) -> bool:
2462 """Return True if this line item is fully allocated."""
2463 # If the linked part is "virtual", then we cannot allocate stock against it
2464 if self.part and self.part.virtual:
2465 return True
2467 if self.order.status == SalesOrderStatus.SHIPPED:
2468 return self.fulfilled_quantity() >= self.quantity
2470 return self.allocated_quantity() >= self.quantity
2472 def is_overallocated(self) -> bool:
2473 """Return True if this line item is over allocated."""
2474 return self.allocated_quantity() > self.quantity
2476 def is_completed(self) -> bool:
2477 """Return True if this line item is completed (has been fully shipped)."""
2478 # A "virtual" part is always considered to be "completed"
2479 if self.part and self.part.virtual:
2480 return True
2482 return self.shipped >= self.quantity
2485class SalesOrderShipmentReportContext(report.mixins.BaseReportContext, TypedDict):
2486 """Context for the SalesOrderShipment model.
2488 Attributes:
2489 allocations: QuerySet of SalesOrderAllocation objects
2490 address: The shipping address for this shipment (or order)
2491 order: The associated SalesOrder object
2492 reference: Shipment reference string
2493 shipment: The SalesOrderShipment object itself
2494 tracking_number: Shipment tracking number string
2495 title: Title for the report
2496 """
2498 allocations: report.mixins.QuerySet['SalesOrderAllocation']
2499 order: 'SalesOrder'
2500 reference: str
2501 address: 'Address'
2502 shipment: 'SalesOrderShipment'
2503 tracking_number: str
2504 title: str
2507class SalesOrderShipment(
2508 InvenTree.models.InvenTreeParameterMixin,
2509 InvenTree.models.InvenTreeAttachmentMixin,
2510 InvenTree.models.InvenTreeBarcodeMixin,
2511 InvenTree.models.InvenTreeTagsMixin,
2512 InvenTree.models.InvenTreeNotesMixin,
2513 report.mixins.InvenTreeReportMixin,
2514 InvenTree.models.MetadataMixin,
2515 InvenTree.models.InvenTreeModel,
2516):
2517 """The SalesOrderShipment model represents a physical shipment made against a SalesOrder.
2519 - Points to a single SalesOrder object
2520 - Multiple SalesOrderAllocation objects point to a particular SalesOrderShipment
2521 - When a given SalesOrderShipment is "shipped", stock items are removed from stock
2523 Attributes:
2524 order: SalesOrder reference
2525 shipment_address: Shipping address for this shipment (optional)
2526 shipment_date: Date this shipment was "shipped" (or null)
2527 checked_by: User reference field indicating who checked this order
2528 reference: Custom reference text for this shipment (e.g. consignment number?)
2529 notes: Custom notes field for this shipment
2530 """
2532 @classmethod
2533 def barcode_model_type_code(cls):
2534 """Return the associated barcode model type code for this model."""
2535 return 'SS'
2537 class Meta:
2538 """Metaclass defines extra model options."""
2540 # Shipment reference must be unique for a given sales order
2541 unique_together = ['order', 'reference']
2542 verbose_name = _('Sales Order Shipment')
2544 def clean(self) -> None:
2545 """Custom clean method for the SalesOrderShipment class.
2547 Raises:
2548 ValidationError: If the shipment address does not match the customer
2549 """
2550 super().clean()
2552 if self.order and self.shipment_address:
2553 if self.shipment_address.company != self.order.customer:
2554 raise ValidationError({
2555 'shipment_address': _('Shipment address must match the customer')
2556 })
2558 @staticmethod
2559 def get_api_url() -> str:
2560 """Return the API URL associated with the SalesOrderShipment model."""
2561 return reverse('api-so-shipment-list')
2563 def report_context(self) -> SalesOrderShipmentReportContext:
2564 """Generate context data for the reporting interface."""
2565 return {
2566 'allocations': self.allocations,
2567 'order': self.order,
2568 'address': self.address,
2569 'reference': self.reference,
2570 'shipment': self,
2571 'tracking_number': self.tracking_number,
2572 'title': str(self),
2573 }
2575 order = models.ForeignKey(
2576 SalesOrder,
2577 on_delete=models.CASCADE,
2578 blank=False,
2579 null=False,
2580 related_name='shipments',
2581 verbose_name=_('Order'),
2582 help_text=_('Sales Order'),
2583 )
2585 shipment_address = models.ForeignKey(
2586 Address,
2587 on_delete=models.SET_NULL,
2588 blank=True,
2589 null=True,
2590 verbose_name=_('Address'),
2591 help_text=_('Shipping address for this shipment'),
2592 related_name='+',
2593 )
2595 shipment_date = models.DateField(
2596 null=True,
2597 blank=True,
2598 verbose_name=_('Shipment Date'),
2599 help_text=_('Date of shipment'),
2600 )
2602 delivery_date = models.DateField(
2603 null=True,
2604 blank=True,
2605 verbose_name=_('Delivery Date'),
2606 help_text=_('Date of delivery of shipment'),
2607 )
2609 checked_by = models.ForeignKey(
2610 User,
2611 on_delete=models.SET_NULL,
2612 blank=True,
2613 null=True,
2614 verbose_name=_('Checked By'),
2615 help_text=_('User who checked this shipment'),
2616 related_name='+',
2617 )
2619 reference = models.CharField(
2620 max_length=100,
2621 blank=False,
2622 verbose_name=_('Shipment'),
2623 help_text=_('Shipment number'),
2624 default='1',
2625 )
2627 tracking_number = models.CharField(
2628 max_length=100,
2629 blank=True,
2630 unique=False,
2631 verbose_name=_('Tracking Number'),
2632 help_text=_('Shipment tracking information'),
2633 )
2635 invoice_number = models.CharField(
2636 max_length=100,
2637 blank=True,
2638 unique=False,
2639 verbose_name=_('Invoice Number'),
2640 help_text=_('Reference number for associated invoice'),
2641 )
2643 link = InvenTreeURLField(
2644 blank=True,
2645 verbose_name=_('Link'),
2646 help_text=_('Link to external page'),
2647 max_length=2000,
2648 )
2650 @property
2651 def address(self) -> Address:
2652 """Return the shipping address for this shipment.
2654 Lookup priority:
2655 - Specific address assigned to this shipment
2656 - Address assigned to the order
2657 - Primary address of the customer
2658 """
2659 return (
2660 self.shipment_address
2661 or self.order.address
2662 or self.order.customer.primary_address
2663 )
2665 def is_checked(self) -> bool:
2666 """Return True if this shipment has been checked."""
2667 return self.checked_by is not None
2669 def is_complete(self) -> bool:
2670 """Return True if this shipment has already been completed."""
2671 return self.shipment_date is not None
2673 def is_delivered(self) -> bool:
2674 """Return True if this shipment has already been delivered."""
2675 return self.delivery_date is not None
2677 def check_can_complete(self, raise_error: bool = True) -> bool:
2678 """Check if this shipment is able to be completed.
2680 Arguments:
2681 raise_error: If True, raise ValidationError if cannot complete
2682 """
2683 try:
2684 if self.shipment_date:
2685 # Shipment has already been sent!
2686 raise ValidationError(_('Shipment has already been sent'))
2688 if self.allocations.count() == 0:
2689 raise ValidationError(_('Shipment has no allocated stock items'))
2691 if (
2692 get_global_setting('SALESORDER_SHIPMENT_REQUIRES_CHECK')
2693 and not self.is_checked()
2694 ):
2695 raise ValidationError(
2696 _('Shipment must be checked before it can be completed')
2697 )
2699 except ValidationError as e:
2700 if raise_error:
2701 raise e
2702 else:
2703 return False
2705 return True
2707 @transaction.atomic
2708 def complete_shipment(self, user, **kwargs):
2709 """Complete this particular shipment.
2711 Executes:
2712 1. Update any stock items associated with this shipment
2713 2. Update the "shipped" quantity of all associated line items
2714 3. Set the "shipment_date" to now
2716 Arguments:
2717 user: The user who is completing this shipment
2719 Returns:
2720 task_id: The ID of the background task which is processing this shipment
2721 """
2722 import order.tasks
2724 # Check if the shipment can be completed (throw error if not)
2725 self.check_can_complete()
2727 if tracking_number := kwargs.get('tracking_number'):
2728 self.tracking_number = tracking_number
2730 if invoice_number := kwargs.get('invoice_number'):
2731 self.invoice_number = invoice_number
2733 if link := kwargs.get('link'):
2734 self.link = link
2736 self.save()
2738 # Extract shipment date and delivery date from kwargs (if provided)
2739 shipment_date = kwargs.get('shipment_date', InvenTree.helpers.current_date())
2740 delivery_date = kwargs.get('delivery_date')
2742 # Offload the "completion" of each line item to the background worker
2743 # This may take some time, and we don't want to block the main thread
2744 task_id = InvenTree.tasks.offload_task(
2745 order.tasks.complete_sales_order_shipment,
2746 self.pk,
2747 user.pk if user else None,
2748 shipment_date,
2749 delivery_date=delivery_date,
2750 group='sales_order',
2751 )
2753 return task_id
2756class SalesOrderExtraLine(OrderExtraLine):
2757 """Model for a single ExtraLine in a SalesOrder.
2759 Attributes:
2760 order: Link to the SalesOrder that this line belongs to
2761 """
2763 class Meta:
2764 """Model meta options."""
2766 verbose_name = _('Sales Order Extra Line')
2768 @staticmethod
2769 def get_api_url():
2770 """Return the API URL associated with the SalesOrderExtraLine model."""
2771 return reverse('api-so-extra-line-list')
2773 order = models.ForeignKey(
2774 SalesOrder,
2775 on_delete=models.CASCADE,
2776 related_name='extra_lines',
2777 verbose_name=_('Order'),
2778 help_text=_('Sales Order'),
2779 )
2782class SalesOrderAllocation(models.Model):
2783 """This model is used to 'allocate' stock items to a SalesOrder. Items that are "allocated" to a SalesOrder are not yet "attached" to the order, but they will be once the order is fulfilled.
2785 Attributes:
2786 line: SalesOrderLineItem reference
2787 shipment: SalesOrderShipment reference
2788 item: StockItem reference
2789 quantity: Quantity to take from the StockItem
2790 """
2792 class Meta:
2793 """Model meta options."""
2795 verbose_name = _('Sales Order Allocation')
2797 @staticmethod
2798 def get_api_url():
2799 """Return the API URL associated with the SalesOrderAllocation model."""
2800 return reverse('api-so-allocation-list')
2802 def clean(self):
2803 """Validate the SalesOrderAllocation object.
2805 Executes:
2806 - Cannot allocate stock to a line item without a part reference
2807 - The referenced part must match the part associated with the line item
2808 - Allocated quantity cannot exceed the quantity of the stock item
2809 - Allocation quantity must be "1" if the StockItem is serialized
2810 - Allocation quantity cannot be zero
2811 """
2812 super().clean()
2814 errors = {}
2816 try:
2817 if not self.item:
2818 raise ValidationError({'item': _('Stock item has not been assigned')})
2819 except stock.models.StockItem.DoesNotExist:
2820 raise ValidationError({'item': _('Stock item has not been assigned')})
2822 try:
2823 if self.line.part != self.item.part:
2824 variants = self.line.part.get_descendants(include_self=True)
2825 if self.line.part not in variants:
2826 errors['item'] = _(
2827 'Cannot allocate stock item to a line with a different part'
2828 )
2829 except PartModels.Part.DoesNotExist:
2830 errors['line'] = _('Cannot allocate stock to a line without a part')
2832 if self.quantity > self.item.quantity:
2833 errors['quantity'] = _('Allocation quantity cannot exceed stock quantity')
2835 # Ensure that we do not 'over allocate' a stock item
2836 build_allocation_count = self.item.build_allocation_count()
2837 sales_allocation_count = self.item.sales_order_allocation_count(
2838 exclude_allocations={'pk': self.pk}
2839 )
2841 total_allocation = (
2842 build_allocation_count + sales_allocation_count + self.quantity
2843 )
2845 if total_allocation > self.item.quantity:
2846 errors['quantity'] = _('Stock item is over-allocated')
2848 if self.quantity <= 0:
2849 errors['quantity'] = _('Allocation quantity must be greater than zero')
2851 if self.item.serial and self.quantity != 1:
2852 errors['quantity'] = _('Quantity must be 1 for serialized stock item')
2854 if self.shipment and self.line.order != self.shipment.order:
2855 errors['line'] = _('Sales order does not match shipment')
2856 errors['shipment'] = _('Shipment does not match sales order')
2858 if len(errors) > 0:
2859 raise ValidationError(errors)
2861 line = models.ForeignKey(
2862 SalesOrderLineItem,
2863 on_delete=models.CASCADE,
2864 verbose_name=_('Line'),
2865 related_name='allocations',
2866 )
2868 shipment = models.ForeignKey(
2869 SalesOrderShipment,
2870 on_delete=models.CASCADE,
2871 null=True,
2872 blank=True,
2873 related_name='allocations',
2874 verbose_name=_('Shipment'),
2875 help_text=_('Sales order shipment reference'),
2876 )
2878 item = models.ForeignKey(
2879 'stock.StockItem',
2880 on_delete=models.CASCADE,
2881 related_name='sales_order_allocations',
2882 limit_choices_to={
2883 'part__salable': True,
2884 'part__virtual': False,
2885 'belongs_to': None,
2886 'sales_order': None,
2887 },
2888 verbose_name=_('Item'),
2889 help_text=_('Select stock item to allocate'),
2890 )
2892 quantity = RoundingDecimalField(
2893 max_digits=15,
2894 decimal_places=5,
2895 validators=[MinValueValidator(0)],
2896 default=1,
2897 verbose_name=_('Quantity'),
2898 help_text=_('Enter stock allocation quantity'),
2899 )
2901 def get_location(self):
2902 """Return the <pk> value of the location associated with this allocation."""
2903 return self.item.location.id if self.item.location else None
2905 def get_po(self):
2906 """Return the PurchaseOrder associated with this allocation."""
2907 return self.item.purchase_order
2909 def complete_allocation(self, user):
2910 """Complete this allocation (called when the parent SalesOrder is marked as "shipped").
2912 Executes:
2913 - Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity)
2914 - Mark the StockItem as belonging to the Customer (this will remove it from stock)
2915 """
2916 order = self.line.order
2918 item = self.item.allocateToCustomer(
2919 order.customer, quantity=self.quantity, order=order, user=user
2920 )
2922 # Update the 'shipped' quantity
2923 # Increment at the database level to prevent lost updates
2924 self.line.shipped = F('shipped') + self.quantity
2925 self.line.save(update_fields=['shipped'])
2926 self.line.refresh_from_db(fields=['shipped'])
2928 # Update our own reference to the StockItem
2929 # (It may have changed if the stock was split)
2930 self.item = item
2931 self.save()
2934class ReturnOrder(TotalPriceMixin, Order):
2935 """A ReturnOrder represents goods returned from a customer, e.g. an RMA or warranty.
2937 Attributes:
2938 customer: Reference to the customer
2939 sales_order: Reference to an existing SalesOrder (optional)
2940 status: The status of the order (refer to status_codes.ReturnOrderStatus)
2941 """
2943 REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN'
2944 REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE'
2945 STATUS_CLASS = ReturnOrderStatus
2946 UNLOCK_SETTING = 'RETURNORDER_EDIT_COMPLETED_ORDERS'
2948 class Meta:
2949 """Model meta options."""
2951 verbose_name = _('Return Order')
2953 def clean_line_item(self, line):
2954 """Clean a line item for this ReturnOrder."""
2955 super().clean_line_item(line)
2956 line.received_date = None
2957 line.outcome = ReturnOrderLineStatus.PENDING.value
2959 def report_context(self) -> ReturnOrderReportContext:
2960 """Generate report context data for this ReturnOrder."""
2961 return_ctx = super().report_context()
2963 return_ctx.update({'customer': self.customer}) # ty:ignore[invalid-key]
2964 return return_ctx
2966 def get_absolute_url(self):
2967 """Get the 'web' URL for this order."""
2968 return pui_url(f'/sales/return-order/{self.pk}')
2970 @staticmethod
2971 def get_api_url():
2972 """Return the API URL associated with the ReturnOrder model."""
2973 return reverse('api-return-order-list')
2975 @classmethod
2976 def get_status_class(cls):
2977 """Return the ReturnOrderStatus class."""
2978 return ReturnOrderStatusGroups
2980 @classmethod
2981 def api_defaults(cls, request=None):
2982 """Return default values for this model when issuing an API OPTIONS request."""
2983 defaults = {
2984 'reference': order.validators.generate_next_return_order_reference()
2985 }
2987 return defaults
2989 @classmethod
2990 def barcode_model_type_code(cls):
2991 """Return the associated barcode model type code for this model."""
2992 return 'RO'
2994 def subscribed_users(self) -> list[User]:
2995 """Return a list of users subscribed to this ReturnOrder.
2997 By this, we mean users to are interested in any of the parts associated with this order.
2998 """
2999 subscribed_users = set()
3001 for line in self.lines.all():
3002 if line.item and line.item.part:
3003 # Add the part to the list of subscribed users
3004 for user in line.item.part.get_subscribers():
3005 subscribed_users.add(user)
3007 return list(subscribed_users)
3009 def __str__(self):
3010 """Render a string representation of this ReturnOrder."""
3011 return f'{self.reference} - {self.customer.name if self.customer else _("no customer")}'
3013 reference = models.CharField(
3014 unique=True,
3015 max_length=64,
3016 blank=False,
3017 verbose_name=_('Reference'),
3018 help_text=_('Return Order reference'),
3019 default=order.validators.generate_next_return_order_reference,
3020 validators=[order.validators.validate_return_order_reference],
3021 )
3023 customer = models.ForeignKey(
3024 Company,
3025 on_delete=models.SET_NULL,
3026 null=True,
3027 limit_choices_to={'is_customer': True},
3028 related_name='sales_orders',
3029 verbose_name=_('Customer'),
3030 help_text=_('Company from which items are being returned'),
3031 )
3033 @property
3034 def company(self):
3035 """Accessor helper for Order base class."""
3036 return self.customer
3038 status = InvenTreeCustomStatusModelField(
3039 default=ReturnOrderStatus.PENDING.value,
3040 choices=ReturnOrderStatus.items(),
3041 status_class=ReturnOrderStatus,
3042 verbose_name=_('Status'),
3043 help_text=_('Return order status'),
3044 )
3046 customer_reference = models.CharField(
3047 max_length=64,
3048 blank=True,
3049 verbose_name=_('Customer Reference '),
3050 help_text=_('Customer order reference code'),
3051 )
3053 complete_date = models.DateField(
3054 blank=True,
3055 null=True,
3056 verbose_name=_('Completion Date'),
3057 help_text=_('Date order was completed'),
3058 )
3060 # region state changes
3061 @property
3062 def is_pending(self):
3063 """Return True if this order is pending."""
3064 return self.status == ReturnOrderStatus.PENDING
3066 @property
3067 def is_open(self):
3068 """Return True if this order is outstanding."""
3069 return self.status in ReturnOrderStatusGroups.OPEN
3071 @property
3072 def is_received(self):
3073 """Return True if this order is fully received."""
3074 return not self.lines.filter(received_date=None).exists()
3076 @property
3077 def can_hold(self):
3078 """Return True if this order can be placed on hold."""
3079 return self.status in [
3080 ReturnOrderStatus.PENDING.value,
3081 ReturnOrderStatus.IN_PROGRESS.value,
3082 ]
3084 def _action_hold(self, *args, **kwargs):
3085 """Mark this order as 'on hold' (if allowed)."""
3086 if self.can_hold:
3087 self.status = ReturnOrderStatus.ON_HOLD.value
3088 self.save()
3090 trigger_event(ReturnOrderEvents.HOLD, id=self.pk)
3092 @property
3093 def can_cancel(self):
3094 """Return True if this order can be cancelled."""
3095 return self.status in ReturnOrderStatusGroups.OPEN
3097 def _action_cancel(self, *args, **kwargs):
3098 """Cancel this ReturnOrder (if not already cancelled)."""
3099 if self.can_cancel:
3100 self.status = ReturnOrderStatus.CANCELLED.value
3101 self.save()
3103 trigger_event(ReturnOrderEvents.CANCELLED, id=self.pk)
3105 # Notify users that the order has been canceled
3106 notify_responsible(
3107 self,
3108 ReturnOrder,
3109 exclude=self.created_by,
3110 content=InvenTreeNotificationBodies.OrderCanceled,
3111 extra_users=self.subscribed_users(),
3112 )
3114 def _action_complete(self, *args, **kwargs):
3115 """Complete this ReturnOrder (if not already completed)."""
3116 # Lock this order against concurrent completion, and re-read the status
3117 # from the database. Without this, two simultaneous completion requests
3118 # can both observe status=IN_PROGRESS, and each would run the completion
3119 # side effects (duplicate events and notifications).
3120 self.status = ReturnOrder.objects.select_for_update().get(pk=self.pk).status
3122 if self.status == ReturnOrderStatus.IN_PROGRESS.value:
3123 self.status = ReturnOrderStatus.COMPLETE.value
3124 self.complete_date = InvenTree.helpers.current_date()
3125 self.save()
3127 trigger_event(ReturnOrderEvents.COMPLETED, id=self.pk)
3129 def place_order(self):
3130 """Deprecated version of 'issue_order."""
3131 self.issue_order()
3133 @property
3134 def can_issue(self):
3135 """Return True if this order can be issued."""
3136 return self.status in [
3137 ReturnOrderStatus.PENDING.value,
3138 ReturnOrderStatus.ON_HOLD.value,
3139 ]
3141 def _action_place(self, *args, **kwargs):
3142 """Issue this ReturnOrder (if currently pending)."""
3143 if self.can_issue:
3144 self.status = ReturnOrderStatus.IN_PROGRESS.value
3145 self.issue_date = InvenTree.helpers.current_date()
3146 self.save()
3148 trigger_event(ReturnOrderEvents.ISSUED, id=self.pk)
3150 # Notify users that the order has been placed
3151 notify_responsible(
3152 self,
3153 ReturnOrder,
3154 exclude=self.created_by,
3155 content=InvenTreeNotificationBodies.NewOrder,
3156 extra_users=self.subscribed_users(),
3157 )
3159 @transaction.atomic
3160 def hold_order(self):
3161 """Attempt to transition to ON_HOLD status."""
3162 return self.handle_transition(
3163 self.status, ReturnOrderStatus.ON_HOLD.value, self, self._action_hold
3164 )
3166 @transaction.atomic
3167 def issue_order(self):
3168 """Attempt to transition to IN_PROGRESS status."""
3169 return self.handle_transition(
3170 self.status, ReturnOrderStatus.IN_PROGRESS.value, self, self._action_place
3171 )
3173 @transaction.atomic
3174 def complete_order(self):
3175 """Attempt to transition to COMPLETE status."""
3176 return self.handle_transition(
3177 self.status, ReturnOrderStatus.COMPLETE.value, self, self._action_complete
3178 )
3180 @transaction.atomic
3181 def cancel_order(self):
3182 """Attempt to transition to CANCELLED status."""
3183 return self.handle_transition(
3184 self.status, ReturnOrderStatus.CANCELLED.value, self, self._action_cancel
3185 )
3187 # endregion
3189 @transaction.atomic
3190 def receive_line_item(self, line, location, user, **kwargs):
3191 """Receive a line item against this ReturnOrder.
3193 Arguments:
3194 line: ReturnOrderLineItem to receive
3195 location: StockLocation to receive the item to
3196 user: User performing the action
3198 Keyword Arguments:
3199 note: Additional notes to add to the tracking entry
3200 status: Status to set the StockItem to (default: StockStatus.QUARANTINED)
3202 Performs the following actions:
3203 - Transfers the StockItem to the specified location
3204 - Marks the StockItem as "quarantined"
3205 - Adds a tracking entry to the StockItem
3206 - Removes the 'customer' reference from the StockItem
3207 """
3208 # Lock the line item row against concurrent receipt, and re-read it
3209 # from the database. Without this, two simultaneous receipt requests
3210 # can both observe received_date=None, and each would split / process
3211 # the associated stock item.
3212 line = ReturnOrderLineItem.objects.select_for_update().get(pk=line.pk)
3214 # Prevent an item from being "received" multiple times
3215 if line.received_date is not None:
3216 logger.warning('receive_line_item called with item already returned')
3217 return
3219 stock_item = line.item
3221 if not stock_item.serialized and line.quantity < stock_item.quantity:
3222 # Split the stock item if we are returning less than the full quantity
3223 stock_item = stock_item.splitStock(line.quantity, user=user)
3225 # Update the line item to point to the *new* stock item
3226 line.item = stock_item
3227 line.save()
3229 status = kwargs.get('status', StockStatus.QUARANTINED.value)
3231 if status is None:
3232 status = StockStatus.QUARANTINED.value
3234 deltas = {
3235 'status': status,
3236 'returnorder': self.pk,
3237 'location': location.pk,
3238 'quantity': float(line.quantity),
3239 }
3241 if stock_item.customer:
3242 deltas['customer'] = stock_item.customer.pk
3244 # Update the StockItem
3245 stock_item.set_status(status)
3246 stock_item.location = location
3247 stock_item.customer = None
3248 stock_item.sales_order = None
3249 stock_item.save(add_note=False)
3250 stock_item.clearAllocations()
3252 # Add a tracking entry to the StockItem
3253 stock_item.add_tracking_entry(
3254 StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER,
3255 user,
3256 notes=kwargs.get('note', ''),
3257 deltas=deltas,
3258 location=location,
3259 returnorder=self,
3260 )
3262 # Update the LineItem
3263 line.received_date = InvenTree.helpers.current_date()
3264 line.save()
3266 trigger_event(ReturnOrderEvents.RECEIVED, id=self.pk, line_item_id=line.pk)
3268 # Notify responsible users
3269 notify_responsible(
3270 self,
3271 ReturnOrder,
3272 exclude=user,
3273 content=InvenTreeNotificationBodies.ReturnOrderItemsReceived,
3274 extra_users=line.item.part.get_subscribers(),
3275 )
3278class ReturnOrderLineItem(StatusCodeMixin, OrderLineItem):
3279 """Model for a single LineItem in a ReturnOrder."""
3281 STATUS_CLASS = ReturnOrderLineStatus
3282 STATUS_FIELD = 'outcome'
3284 class Meta:
3285 """Metaclass options for this model."""
3287 verbose_name = _('Return Order Line Item')
3288 unique_together = [('order', 'item')]
3290 @staticmethod
3291 def get_api_url():
3292 """Return the API URL associated with this model."""
3293 return reverse('api-return-order-line-list')
3295 def clean(self):
3296 """Perform extra validation steps for the ReturnOrderLineItem model."""
3297 super().clean()
3299 if not self.item:
3300 raise ValidationError({'item': _('Stock item must be specified')})
3302 if self.quantity > self.item.quantity:
3303 raise ValidationError({
3304 'quantity': _('Return quantity exceeds stock quantity')
3305 })
3307 if self.quantity <= 0:
3308 raise ValidationError({
3309 'quantity': _('Return quantity must be greater than zero')
3310 })
3312 if self.item.serialized and self.quantity != 1:
3313 raise ValidationError({
3314 'quantity': _('Invalid quantity for serialized stock item')
3315 })
3317 order = models.ForeignKey(
3318 ReturnOrder,
3319 on_delete=models.CASCADE,
3320 related_name='lines',
3321 verbose_name=_('Order'),
3322 help_text=_('Return Order'),
3323 )
3325 item = models.ForeignKey(
3326 stock.models.StockItem,
3327 on_delete=models.CASCADE,
3328 related_name='return_order_lines',
3329 verbose_name=_('Item'),
3330 help_text=_('Select item to return from customer'),
3331 )
3333 quantity = models.DecimalField(
3334 verbose_name=('Quantity'),
3335 help_text=('Quantity to return'),
3336 max_digits=15,
3337 decimal_places=5,
3338 validators=[MinValueValidator(0)],
3339 default=1,
3340 )
3342 received_date = models.DateField(
3343 null=True,
3344 blank=True,
3345 verbose_name=_('Received Date'),
3346 help_text=_('The date this return item was received'),
3347 )
3349 @property
3350 def received(self):
3351 """Return True if this item has been received."""
3352 return self.received_date is not None
3354 outcome = InvenTreeCustomStatusModelField(
3355 default=ReturnOrderLineStatus.PENDING.value,
3356 choices=ReturnOrderLineStatus.items(),
3357 status_class=ReturnOrderLineStatus,
3358 verbose_name=_('Outcome'),
3359 help_text=_('Outcome for this line item'),
3360 )
3362 price = InvenTreeModelMoneyField(
3363 null=True,
3364 blank=True,
3365 verbose_name=_('Price'),
3366 help_text=_('Cost associated with return or repair for this line item'),
3367 )
3370class ReturnOrderExtraLine(OrderExtraLine):
3371 """Model for a single ExtraLine in a ReturnOrder."""
3373 class Meta:
3374 """Metaclass options for this model."""
3376 verbose_name = _('Return Order Extra Line')
3378 @staticmethod
3379 def get_api_url():
3380 """Return the API URL associated with the ReturnOrderExtraLine model."""
3381 return reverse('api-return-order-extra-line-list')
3383 order = models.ForeignKey(
3384 ReturnOrder,
3385 on_delete=models.CASCADE,
3386 related_name='extra_lines',
3387 verbose_name=_('Order'),
3388 help_text=_('Return Order'),
3389 )
3392class TransferOrder(Order):
3393 """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.
3395 Attributes:
3396 take_from: The stock location to source items from (or null to )
3397 destination: The stock location to move items to
3398 consume: Rather than move the stock, "consume" it. Helpful if you want to queue up removing stock from inventory
3399 """
3401 # Global setting for specifying reference pattern
3402 REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN'
3403 REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE'
3404 STATUS_CLASS = TransferOrderStatus
3405 # UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
3407 class Meta:
3408 """Model meta options."""
3410 verbose_name = _('Transfer Order')
3412 def report_context(self) -> TransferOrderReportContext:
3413 """Return report context data for this TransferOrder."""
3414 return {
3415 **super().report_context(),
3416 'take_from': self.take_from,
3417 'destination': self.destination,
3418 'consume': self.consume,
3419 }
3421 def get_absolute_url(self) -> str:
3422 """Get the 'web' URL for this order."""
3423 return pui_url(f'/stock/transfer-order/{self.pk}')
3425 @staticmethod
3426 def get_api_url() -> str:
3427 """Return the API URL associated with the TransferOrder model."""
3428 return reverse('api-transfer-order-list')
3430 @classmethod
3431 def get_status_class(cls):
3432 """Return the TransferOrderStatus class."""
3433 return TransferOrderStatusGroups
3435 @classmethod
3436 def api_defaults(cls, request=None):
3437 """Return default values for this model when issuing an API OPTIONS request."""
3438 defaults = {
3439 'reference': order.validators.generate_next_transfer_order_reference()
3440 }
3442 return defaults
3444 @classmethod
3445 def barcode_model_type_code(cls):
3446 """Return the associated barcode model type code for this model."""
3447 return 'TO'
3449 def subscribed_users(self) -> list[User]:
3450 """Return a list of users subscribed to this TransferOrder.
3452 By this, we mean users to are interested in any of the parts associated with this order.
3453 """
3454 subscribed_users = set()
3456 for line in self.lines.all(): 3456 ↛ 3457line 3456 didn't jump to line 3457 because the loop on line 3456 never started
3457 if line.part:
3458 # Add the part to the list of subscribed users
3459 for user in line.part.get_subscribers():
3460 subscribed_users.add(user)
3462 return list(subscribed_users)
3464 def clean_line_item(self, line):
3465 """Clean a line item for this PurchaseOrder."""
3466 super().clean_line_item(line)
3467 line.transferred = 0
3469 def __str__(self):
3470 """Render a string representation of this TransferOrder."""
3471 return f'{self.reference} - {self.take_from.name if self.take_from else _("deleted")} --> {self.destination.name if self.destination else _("deleted")}'
3473 reference = models.CharField(
3474 unique=True,
3475 max_length=64,
3476 blank=False,
3477 help_text=_('Transfer Order Reference'),
3478 verbose_name=_('Reference'),
3479 default=order.validators.generate_next_transfer_order_reference,
3480 validators=[order.validators.validate_transfer_order_reference],
3481 )
3483 status = InvenTreeCustomStatusModelField(
3484 default=TransferOrderStatus.PENDING.value,
3485 choices=TransferOrderStatus.items(),
3486 status_class=TransferOrderStatus,
3487 verbose_name=_('Status'),
3488 help_text=_('Transfer order status'),
3489 )
3491 @property
3492 def status_text(self):
3493 """Return the text representation of the status field."""
3494 return TransferOrderStatus.text(self.status)
3496 take_from = models.ForeignKey(
3497 'stock.StockLocation',
3498 verbose_name=_('Source Location'),
3499 on_delete=models.SET_NULL,
3500 related_name='sourcing_transfers',
3501 blank=True,
3502 null=True,
3503 help_text=_('Source for transferred items'),
3504 )
3506 destination = models.ForeignKey(
3507 'stock.StockLocation',
3508 verbose_name=_('Destination Location'),
3509 on_delete=models.SET_NULL,
3510 related_name='incoming_transfers',
3511 blank=True,
3512 null=True,
3513 help_text=_('Destination for transferred items'),
3514 )
3516 consume = models.BooleanField(
3517 default=False,
3518 verbose_name=_('Consume Stock'),
3519 help_text=_(
3520 'Rather than transfer the stock to the destination, "consume" it, by removing transferred quantity from the allocated stock item'
3521 ),
3522 )
3524 complete_date = models.DateField(
3525 blank=True,
3526 null=True,
3527 verbose_name=_('Completion Date'),
3528 help_text=_('Date order was completed'),
3529 )
3531 @property
3532 def company(self) -> None:
3533 """Required accessor helper for Order base class."""
3534 return None
3536 @property
3537 def is_pending(self) -> bool:
3538 """Return True if the TransferOrder is 'pending'."""
3539 return self.status == TransferOrderStatus.PENDING.value
3541 @property
3542 def is_open(self) -> bool:
3543 """Return True if the TransferOrder is 'open'."""
3544 return self.status in TransferOrderStatusGroups.OPEN
3546 @property
3547 def stock_allocations(self) -> QuerySet:
3548 """Return a queryset containing all allocations for this order."""
3549 return TransferOrderAllocation.objects.filter(
3550 line__in=[line.pk for line in self.lines.all()]
3551 )
3553 def is_fully_allocated(self) -> bool:
3554 """Return True if all line items are fully allocated."""
3555 return all(line.is_fully_allocated() for line in self.lines.all())
3557 def is_overallocated(self) -> bool:
3558 """Return true if any lines in the order are over-allocated."""
3559 return any(line.is_overallocated() for line in self.lines.all())
3561 def is_completed(self) -> bool:
3562 """Check if this order is "transferred" (all line items transferred)."""
3563 return all(line.is_completed() for line in self.lines.all())
3565 def can_complete(
3566 self, raise_error: bool = False, allow_incomplete_lines: bool = False
3567 ) -> bool:
3568 """Test if this TransferOrder can be completed."""
3569 try:
3570 if self.status == TransferOrderStatus.COMPLETE.value: 3570 ↛ 3571line 3570 didn't jump to line 3571 because the condition on line 3570 was never true
3571 raise ValidationError(_('Order is already complete'))
3573 if self.status == TransferOrderStatus.CANCELLED.value: 3573 ↛ 3576line 3573 didn't jump to line 3576 because the condition on line 3573 was always true
3574 raise ValidationError(_('Order is already cancelled'))
3576 if not self.consume and not self.destination:
3577 raise ValidationError(
3578 _('Order cannot be completed until a destination location is set')
3579 )
3581 if not (self.is_fully_allocated() or allow_incomplete_lines):
3582 raise ValidationError(
3583 _('Order cannot be completed until it is fully allocated')
3584 )
3585 except ValidationError as e:
3586 if raise_error: 3586 ↛ 3589line 3586 didn't jump to line 3589 because the condition on line 3586 was always true
3587 raise e
3588 else:
3589 return False
3591 return True
3593 @property
3594 def can_issue(self) -> bool:
3595 """Return True if this order can be issued."""
3596 return self.status in [
3597 TransferOrderStatus.PENDING.value,
3598 TransferOrderStatus.ON_HOLD.value,
3599 ]
3601 @transaction.atomic
3602 def issue_order(self):
3603 """Attempt to transition to PLACED status."""
3604 return self.handle_transition(
3605 self.status, TransferOrderStatus.ISSUED.value, self, self._action_issue
3606 )
3608 # region state changes
3609 def _action_issue(self, *args, **kwargs):
3610 """Marks the TransferOrder as ISSUED.
3612 Order must be currently PENDING.
3613 """
3614 if self.can_issue: 3614 ↛ 3615line 3614 didn't jump to line 3615 because the condition on line 3614 was never true
3615 self.status = TransferOrderStatus.ISSUED.value
3616 self.issue_date = InvenTree.helpers.current_date()
3617 self.save()
3619 trigger_event(TransferOrderEvents.ISSUED, id=self.pk)
3621 # Notify users that the order has been issued
3622 notify_responsible(
3623 self,
3624 TransferOrder,
3625 exclude=self.created_by,
3626 content=InvenTreeNotificationBodies.NewOrder,
3627 extra_users=self.subscribed_users(),
3628 )
3630 @property
3631 def can_hold(self) -> bool:
3632 """Return True if this order can be placed on hold."""
3633 return self.status in [
3634 TransferOrderStatus.PENDING.value,
3635 TransferOrderStatus.ISSUED.value,
3636 ]
3638 def _action_hold(self, *args, **kwargs):
3639 """Mark this transfer order as 'on hold'."""
3640 if self.can_hold: 3640 ↛ 3641line 3640 didn't jump to line 3641 because the condition on line 3640 was never true
3641 self.status = TransferOrderStatus.ON_HOLD.value
3642 self.save()
3644 trigger_event(TransferOrderEvents.HOLD, id=self.pk)
3646 @transaction.atomic
3647 def _action_complete(self, *args, **kwargs):
3648 """Marks the TransferOrder as COMPLETE.
3650 Order must be currently ISSUED.
3651 """
3652 user = kwargs.pop('user', None)
3654 # Lock this order against concurrent completion, and re-read the status
3655 # from the database. Without this, two simultaneous completion requests
3656 # can both observe status=ISSUED, and each would process every allocation
3657 # (duplicating all associated stock operations).
3658 self.status = TransferOrder.objects.select_for_update().get(pk=self.pk).status
3660 if not self.can_complete(raise_error=True, **kwargs):
3661 return False
3663 if self.status == TransferOrderStatus.ISSUED:
3664 for allocation in self.allocations():
3665 # execute each transfer
3666 allocation.complete_allocation(user)
3668 self.status = TransferOrderStatus.COMPLETE.value
3669 self.complete_date = InvenTree.helpers.current_date()
3671 self.save()
3673 trigger_event(TransferOrderEvents.COMPLETED, id=self.pk)
3675 return True
3677 @transaction.atomic
3678 def complete_order(self, user, **kwargs):
3679 """Attempt to transition to COMPLETE status."""
3680 return self.handle_transition(
3681 self.status,
3682 TransferOrderStatus.COMPLETE.value,
3683 self,
3684 self._action_complete,
3685 user=user,
3686 **kwargs,
3687 )
3689 @transaction.atomic
3690 def hold_order(self):
3691 """Attempt to transition to ON_HOLD status."""
3692 return self.handle_transition(
3693 self.status, TransferOrderStatus.ON_HOLD.value, self, self._action_hold
3694 )
3696 @transaction.atomic
3697 def cancel_order(self):
3698 """Attempt to transition to CANCELLED status."""
3699 return self.handle_transition(
3700 self.status, TransferOrderStatus.CANCELLED.value, self, self._action_cancel
3701 )
3703 @property
3704 def can_cancel(self) -> bool:
3705 """A TransferOrder can only be cancelled under the following circumstances.
3707 - Status is ISSUED
3708 - Status is PENDING (or ON_HOLD)
3709 """
3710 return self.status in TransferOrderStatusGroups.OPEN
3712 def _action_cancel(self, *args, **kwargs):
3713 """Cancel this TransferOrder (only if we're allowed to).
3715 Executes:
3716 - Mark the order as 'cancelled'
3717 - Delete any StockItems which have been allocated
3718 """
3719 if not self.can_cancel: 3719 ↛ 3720line 3719 didn't jump to line 3720 because the condition on line 3719 was never true
3720 return False
3722 self.status = TransferOrderStatus.CANCELLED.value
3723 self.save()
3725 # delete allocations
3726 for line in self.lines.all(): 3726 ↛ 3727line 3726 didn't jump to line 3727 because the loop on line 3726 never started
3727 for allocation in line.allocations.all():
3728 allocation.delete()
3730 trigger_event(TransferOrderEvents.CANCELLED, id=self.pk)
3732 # Notify users that the order has been canceled
3733 notify_responsible(
3734 self,
3735 TransferOrder,
3736 exclude=self.created_by,
3737 content=InvenTreeNotificationBodies.OrderCanceled,
3738 extra_users=self.subscribed_users(),
3739 )
3741 # endregion
3743 @property
3744 def line_count(self) -> int:
3745 """Return the total number of lines associated with this order."""
3746 return self.lines.count()
3748 def completed_line_items(self) -> QuerySet:
3749 """Return a queryset of the completed line items for this order."""
3750 return self.lines.filter(transferred__gte=F('quantity'))
3752 def pending_line_items(self) -> QuerySet:
3753 """Return a queryset of the pending line items for this order."""
3754 return self.lines.filter(transferred__lt=F('quantity'))
3756 @property
3757 def completed_line_count(self) -> int:
3758 """Return the number of completed lines for this order."""
3759 return self.completed_line_items().count()
3761 @property
3762 def pending_line_count(self) -> int:
3763 """Return the number of pending (incomplete) lines associated with this order."""
3764 return self.pending_line_items().count()
3766 def allocations(self) -> QuerySet:
3767 """Return a queryset of all allocations for this order."""
3768 return TransferOrderAllocation.objects.filter(line__order=self)
3771class TransferOrderLineItem(OrderLineItem):
3772 """Model for a single LineItem in a TransferOrder.
3774 Attributes:
3775 order: Link to the TransferOrder that this line item belongs to
3776 part: Link to a Part object (may be null)
3777 transferred: The number of items which have actually transferred against this line item
3778 """
3780 class Meta:
3781 """Model meta options."""
3783 verbose_name = _('Transfer Order Line Item')
3785 # Filter for determining if a particular TransferOrderLineItem is overdue
3786 OVERDUE_FILTER = (
3787 Q(transferred__lt=F('quantity'))
3788 & ~Q(target_date=None)
3789 & Q(target_date__lt=InvenTree.helpers.current_date())
3790 )
3792 @staticmethod
3793 def get_api_url():
3794 """Return the API URL associated with the TransferOrderLineItem model."""
3795 return reverse('api-transfer-order-line-list')
3797 order = models.ForeignKey(
3798 TransferOrder,
3799 on_delete=models.CASCADE,
3800 related_name='lines',
3801 verbose_name=_('Order'),
3802 help_text=_('Transfer Order'),
3803 )
3805 part = models.ForeignKey(
3806 'part.Part',
3807 on_delete=models.SET_NULL,
3808 related_name='transfer_order_line_items',
3809 null=True,
3810 verbose_name=_('Part'),
3811 help_text=_('Part'),
3812 # limit_choices_to={'salable': True},
3813 )
3815 transferred = RoundingDecimalField(
3816 verbose_name=_('transferred'),
3817 help_text=_('transferred quantity'),
3818 default=0,
3819 max_digits=15,
3820 decimal_places=5,
3821 validators=[MinValueValidator(0)],
3822 )
3824 def allocated_quantity(self):
3825 """Return the total stock quantity allocated to this LineItem.
3827 This is a summation of the quantity of each attached StockItem
3828 """
3829 if not self.pk:
3830 return 0
3832 query = self.allocations.aggregate(
3833 allocated=Coalesce(Sum('quantity'), Decimal(0))
3834 )
3836 return query['allocated']
3838 def is_fully_allocated(self) -> bool:
3839 """Return True if this line item is fully allocated."""
3840 # If the linked part is "virtual", then we cannot allocate stock against it
3841 if self.part and self.part.virtual:
3842 return True
3844 return self.allocated_quantity() >= self.quantity
3846 def is_overallocated(self) -> bool:
3847 """Return True if this line item is over allocated."""
3848 return self.allocated_quantity() > self.quantity
3850 def is_completed(self) -> bool:
3851 """Return True if this line item is completed (has been fully shipped)."""
3852 # A "virtual" part is always considered to be "completed"
3853 if self.part and self.part.virtual:
3854 return True
3856 return self.transferred >= self.quantity
3859class TransferOrderAllocation(models.Model):
3860 """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.
3862 Attributes:
3863 line: TransferOrderLineItem reference
3864 item: StockItem reference
3865 quantity: Quantity to take from the StockItem
3866 """
3868 class Meta:
3869 """Model meta options."""
3871 verbose_name = _('Transfer Order Allocation')
3873 @staticmethod
3874 def get_api_url():
3875 """Return the API URL associated with the TransferOrderAllocation model."""
3876 return reverse('api-transfer-order-allocation-list')
3878 def clean(self):
3879 """Validate the TransferOrderAllocation object.
3881 Executes:
3882 - Cannot allocate stock to a line item without a part reference
3883 - The referenced part must match the part associated with the line item
3884 - Allocated quantity cannot exceed the quantity of the stock item
3885 - Allocation quantity must be "1" if the StockItem is serialized
3886 - Allocation quantity cannot be zero
3887 """
3888 super().clean()
3890 errors = {}
3892 try:
3893 if not self.item:
3894 raise ValidationError({'item': _('Stock item has not been assigned')})
3895 except stock.models.StockItem.DoesNotExist:
3896 raise ValidationError({'item': _('Stock item has not been assigned')})
3898 try:
3899 if self.line.part != self.item.part:
3900 variants = self.line.part.get_descendants(include_self=True)
3901 if self.line.part not in variants:
3902 errors['item'] = _(
3903 'Cannot allocate stock item to a line with a different part'
3904 )
3905 except PartModels.Part.DoesNotExist:
3906 errors['line'] = _('Cannot allocate stock to a line without a part')
3908 if self.quantity > self.item.quantity:
3909 errors['quantity'] = _('Allocation quantity cannot exceed stock quantity')
3911 # Ensure that we do not 'over allocate' a stock item
3912 build_allocation_count = self.item.build_allocation_count()
3913 sales_allocation_count = self.item.sales_order_allocation_count(
3914 exclude_allocations={'pk': self.pk}
3915 )
3917 total_allocation = (
3918 build_allocation_count + sales_allocation_count + self.quantity
3919 )
3921 if total_allocation > self.item.quantity:
3922 errors['quantity'] = _('Stock item is over-allocated')
3924 if self.quantity <= 0:
3925 errors['quantity'] = _('Allocation quantity must be greater than zero')
3927 if self.item.serial and self.quantity != 1:
3928 errors['quantity'] = _('Quantity must be 1 for serialized stock item')
3930 if len(errors) > 0:
3931 raise ValidationError(errors)
3933 line = models.ForeignKey(
3934 TransferOrderLineItem,
3935 on_delete=models.CASCADE,
3936 verbose_name=_('Line'),
3937 related_name='allocations',
3938 )
3940 item = models.ForeignKey(
3941 'stock.StockItem',
3942 on_delete=models.CASCADE,
3943 related_name='transfer_order_allocations',
3944 limit_choices_to={
3945 'part__virtual': False,
3946 'belongs_to': None,
3947 'sales_order': None,
3948 },
3949 verbose_name=_('Item'),
3950 help_text=_('Select stock item to allocate'),
3951 )
3953 quantity = RoundingDecimalField(
3954 max_digits=15,
3955 decimal_places=5,
3956 validators=[MinValueValidator(0)],
3957 default=1,
3958 verbose_name=_('Quantity'),
3959 help_text=_('Enter stock allocation quantity'),
3960 )
3962 def get_location(self):
3963 """Return the <pk> value of the location associated with this allocation."""
3964 return self.item.location.id if self.item.location else None
3966 def get_po(self):
3967 """Return the PurchaseOrder associated with this allocation."""
3968 return self.item.purchase_order
3970 def complete_allocation(self, user):
3971 """Complete this allocation (called when the parent TransferOrder is marked as "completed").
3973 Executes:
3974 - Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity)
3975 - Move the StockItem to the new location
3976 - Updates the transferred qty
3977 - If order is marked as "consume", reduce quantity rather than move
3979 Raises:
3980 ValidationError: If the stock operation fails - the 'transferred' quantity
3981 is only updated once the stock has actually been moved / consumed.
3982 """
3983 order: TransferOrder = self.line.order
3984 self.item: stock.models.StockItem # for type hints
3985 self.line: TransferOrderLineItem # for type hints
3987 # The allocation is the only thing linking this stock item to the transfer
3988 # As a result, we must keep the allocation present even after completion
3989 # This means allocations to transfer orders don't affect "available" stock
3990 # (otherwise it would permanently reduce available stock)
3992 # The stock item may have been reduced since the allocation was made,
3993 # so limit the transfer to the quantity which is actually available
3994 transfer_quantity = min(self.quantity, self.item.quantity)
3996 if transfer_quantity <= 0:
3997 # Nothing available to transfer (e.g. the item has since been depleted)
3998 return
4000 if order.consume:
4001 # rather than transferring the stock, we simply reduce its quantity to release it from tracked inventory
4002 # NOTE: if delete_on_deplete is enabled, this will result in the "transferred stock" panel being empty
4003 # after completion. A more sophisticated immutable tracking that doesn't rely on allocations
4004 # would be helpful here
4005 if not self.item.take_stock(
4006 quantity=transfer_quantity,
4007 user=user,
4008 code=StockHistoryCode.STOCK_REMOVE,
4009 transferorder=order,
4010 ):
4011 raise ValidationError(
4012 _('Failed to consume stock item against transfer order')
4013 )
4014 elif transfer_quantity < self.item.quantity:
4015 new_item = self.item.splitStock(
4016 quantity=transfer_quantity,
4017 location=order.destination,
4018 user=user,
4019 transferorder=order,
4020 )
4022 if new_item is None:
4023 raise ValidationError(
4024 _('Failed to transfer stock item against transfer order')
4025 )
4027 # update our own reference to the StockItem which was split
4028 self.item = new_item
4029 self.save()
4030 else:
4031 # move item directly, we don't have to split
4032 if not self.item.move(
4033 location=order.destination, user=user, transferorder=order, notes=''
4034 ):
4035 raise ValidationError(
4036 _('Failed to transfer stock item against transfer order')
4037 )
4039 # Update the transferred qty
4040 # Note: use the quantity which was *actually* transferred
4041 # Increment at the database level to prevent lost updates
4042 self.line.transferred = F('transferred') + transfer_quantity
4043 self.line.save(update_fields=['transferred'])
4044 self.line.refresh_from_db(fields=['transferred'])
4047def _touch_order_updated_at(instance):
4048 """Bump updated_at on the parent order without triggering a full save."""
4049 if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
4050 return
4051 instance.order.__class__.objects.filter(pk=instance.order_id).update(
4052 updated_at=InvenTree.helpers.current_time()
4053 )
4056@receiver(post_save, sender=PurchaseOrderLineItem, dispatch_uid='po_lineitem_post_save')
4057@receiver(
4058 post_delete, sender=PurchaseOrderLineItem, dispatch_uid='po_lineitem_post_delete'
4059)
4060@receiver(
4061 post_save, sender=PurchaseOrderExtraLine, dispatch_uid='po_extraline_post_save'
4062)
4063@receiver(
4064 post_delete, sender=PurchaseOrderExtraLine, dispatch_uid='po_extraline_post_delete'
4065)
4066@receiver(post_save, sender=SalesOrderLineItem, dispatch_uid='so_lineitem_post_save')
4067@receiver(
4068 post_delete, sender=SalesOrderLineItem, dispatch_uid='so_lineitem_post_delete'
4069)
4070@receiver(post_save, sender=SalesOrderExtraLine, dispatch_uid='so_extraline_post_save')
4071@receiver(
4072 post_delete, sender=SalesOrderExtraLine, dispatch_uid='so_extraline_post_delete'
4073)
4074@receiver(post_save, sender=ReturnOrderLineItem, dispatch_uid='ro_lineitem_post_save')
4075@receiver(
4076 post_delete, sender=ReturnOrderLineItem, dispatch_uid='ro_lineitem_post_delete'
4077)
4078@receiver(post_save, sender=ReturnOrderExtraLine, dispatch_uid='ro_extraline_post_save')
4079@receiver(
4080 post_delete, sender=ReturnOrderExtraLine, dispatch_uid='ro_extraline_post_delete'
4081)
4082@receiver(
4083 post_save,
4084 sender=TransferOrderLineItem,
4085 dispatch_uid='transfer_order_lineitem_post_save',
4086)
4087@receiver(
4088 post_delete,
4089 sender=TransferOrderLineItem,
4090 dispatch_uid='transfer_order_lineitem_post_delete',
4091)
4092def update_order_on_lineitem_change(sender, instance, **kwargs):
4093 """Update parent order updated_at when any line item is saved or deleted."""
4094 _touch_order_updated_at(instance)