|  |  |  | @@ -336,7 +336,7 @@ class Order( | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         A locked order cannot be modified after it has been completed. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Args: | 
		
	
		
			
				|  |  |  |  |         Arguments: | 
		
	
		
			
				|  |  |  |  |             db: If True, check with the database. If False, check the instance (default False). | 
		
	
		
			
				|  |  |  |  |         """ | 
		
	
		
			
				|  |  |  |  |         if not self.check_complete(db=db): | 
		
	
	
		
			
				
					
					|  |  |  | @@ -351,7 +351,7 @@ class Order( | 
		
	
		
			
				|  |  |  |  |     def check_complete(self, db: bool = False) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Check if this order is 'complete'. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Args: | 
		
	
		
			
				|  |  |  |  |         Arguments: | 
		
	
		
			
				|  |  |  |  |             db: If True, check with the database. If False, check the instance (default False). | 
		
	
		
			
				|  |  |  |  |         """ | 
		
	
		
			
				|  |  |  |  |         status = self.get_db_instance().status if db else self.status | 
		
	
	
		
			
				
					
					|  |  |  | @@ -560,12 +560,12 @@ class PurchaseOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |         """Return report context data for this PurchaseOrder.""" | 
		
	
		
			
				|  |  |  |  |         return {**super().report_context(), 'supplier': self.supplier} | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def get_absolute_url(self): | 
		
	
		
			
				|  |  |  |  |     def get_absolute_url(self) -> str: | 
		
	
		
			
				|  |  |  |  |         """Get the 'web' URL for this order.""" | 
		
	
		
			
				|  |  |  |  |         return pui_url(f'/purchasing/purchase-order/{self.pk}') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @staticmethod | 
		
	
		
			
				|  |  |  |  |     def get_api_url(): | 
		
	
		
			
				|  |  |  |  |     def get_api_url() -> str: | 
		
	
		
			
				|  |  |  |  |         """Return the API URL associated with the PurchaseOrder model.""" | 
		
	
		
			
				|  |  |  |  |         return reverse('api-po-list') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -584,7 +584,7 @@ class PurchaseOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |         return defaults | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @classmethod | 
		
	
		
			
				|  |  |  |  |     def barcode_model_type_code(cls): | 
		
	
		
			
				|  |  |  |  |     def barcode_model_type_code(cls) -> str: | 
		
	
		
			
				|  |  |  |  |         """Return the associated barcode model type code for this model.""" | 
		
	
		
			
				|  |  |  |  |         return 'PO' | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -805,10 +805,10 @@ class PurchaseOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |     @transaction.atomic | 
		
	
		
			
				|  |  |  |  |     def issue_order(self): | 
		
	
		
			
				|  |  |  |  |         """Equivalent to 'place_order'.""" | 
		
	
		
			
				|  |  |  |  |         self.place_order() | 
		
	
		
			
				|  |  |  |  |         return self.place_order() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def can_issue(self): | 
		
	
		
			
				|  |  |  |  |     def can_issue(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this order can be issued.""" | 
		
	
		
			
				|  |  |  |  |         return self.status in [ | 
		
	
		
			
				|  |  |  |  |             PurchaseOrderStatus.PENDING.value, | 
		
	
	
		
			
				
					
					|  |  |  | @@ -844,17 +844,17 @@ class PurchaseOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |         ) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def is_pending(self): | 
		
	
		
			
				|  |  |  |  |     def is_pending(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if the PurchaseOrder is 'pending'.""" | 
		
	
		
			
				|  |  |  |  |         return self.status == PurchaseOrderStatus.PENDING.value | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def is_open(self): | 
		
	
		
			
				|  |  |  |  |     def is_open(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if the PurchaseOrder is 'open'.""" | 
		
	
		
			
				|  |  |  |  |         return self.status in PurchaseOrderStatusGroups.OPEN | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def can_cancel(self): | 
		
	
		
			
				|  |  |  |  |     def can_cancel(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """A PurchaseOrder can only be cancelled under the following circumstances. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         - Status is PLACED | 
		
	
	
		
			
				
					
					|  |  |  | @@ -880,7 +880,7 @@ class PurchaseOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |             ) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def can_hold(self): | 
		
	
		
			
				|  |  |  |  |     def can_hold(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this order can be placed on hold.""" | 
		
	
		
			
				|  |  |  |  |         return self.status in [ | 
		
	
		
			
				|  |  |  |  |             PurchaseOrderStatus.PENDING.value, | 
		
	
	
		
			
				
					
					|  |  |  | @@ -897,34 +897,34 @@ class PurchaseOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     # endregion | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def pending_line_items(self): | 
		
	
		
			
				|  |  |  |  |     def pending_line_items(self) -> QuerySet: | 
		
	
		
			
				|  |  |  |  |         """Return a list of pending line items for this order. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Any line item where 'received' < 'quantity' will be returned. | 
		
	
		
			
				|  |  |  |  |         """ | 
		
	
		
			
				|  |  |  |  |         return self.lines.filter(quantity__gt=F('received')) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def completed_line_items(self): | 
		
	
		
			
				|  |  |  |  |     def completed_line_items(self) -> QuerySet: | 
		
	
		
			
				|  |  |  |  |         """Return a list of completed line items against this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.lines.filter(quantity__lte=F('received')) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def line_count(self): | 
		
	
		
			
				|  |  |  |  |     def line_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the total number of line items associated with this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.lines.count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def completed_line_count(self): | 
		
	
		
			
				|  |  |  |  |     def completed_line_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the number of complete line items associated with this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.completed_line_items().count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def pending_line_count(self): | 
		
	
		
			
				|  |  |  |  |     def pending_line_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the number of pending line items associated with this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.pending_line_items().count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def is_complete(self): | 
		
	
		
			
				|  |  |  |  |     def is_complete(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if all line items have been received.""" | 
		
	
		
			
				|  |  |  |  |         return self.pending_line_items().count() == 0 | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1247,12 +1247,12 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |         """Generate report context data for this SalesOrder.""" | 
		
	
		
			
				|  |  |  |  |         return {**super().report_context(), 'customer': self.customer} | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def get_absolute_url(self): | 
		
	
		
			
				|  |  |  |  |     def get_absolute_url(self) -> str: | 
		
	
		
			
				|  |  |  |  |         """Get the 'web' URL for this order.""" | 
		
	
		
			
				|  |  |  |  |         return pui_url(f'/sales/sales-order/{self.pk}') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @staticmethod | 
		
	
		
			
				|  |  |  |  |     def get_api_url(): | 
		
	
		
			
				|  |  |  |  |     def get_api_url() -> str: | 
		
	
		
			
				|  |  |  |  |         """Return the API URL associated with the SalesOrder model.""" | 
		
	
		
			
				|  |  |  |  |         return reverse('api-so-list') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1262,14 +1262,14 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |         return SalesOrderStatusGroups | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @classmethod | 
		
	
		
			
				|  |  |  |  |     def api_defaults(cls, request=None): | 
		
	
		
			
				|  |  |  |  |     def api_defaults(cls, request=None) -> dict: | 
		
	
		
			
				|  |  |  |  |         """Return default values for this model when issuing an API OPTIONS request.""" | 
		
	
		
			
				|  |  |  |  |         defaults = {'reference': order.validators.generate_next_sales_order_reference()} | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         return defaults | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @classmethod | 
		
	
		
			
				|  |  |  |  |     def barcode_model_type_code(cls): | 
		
	
		
			
				|  |  |  |  |     def barcode_model_type_code(cls) -> str: | 
		
	
		
			
				|  |  |  |  |         """Return the associated barcode model type code for this model.""" | 
		
	
		
			
				|  |  |  |  |         return 'SO' | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1326,7 +1326,7 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |     ) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def status_text(self): | 
		
	
		
			
				|  |  |  |  |     def status_text(self) -> str: | 
		
	
		
			
				|  |  |  |  |         """Return the text representation of the status field.""" | 
		
	
		
			
				|  |  |  |  |         return SalesOrderStatus.text(self.status) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1351,38 +1351,45 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |     ) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def is_pending(self): | 
		
	
		
			
				|  |  |  |  |     def is_pending(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this order is 'pending'.""" | 
		
	
		
			
				|  |  |  |  |         return self.status == SalesOrderStatus.PENDING | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def is_open(self): | 
		
	
		
			
				|  |  |  |  |     def is_open(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this order is 'open' (either 'pending' or 'in_progress').""" | 
		
	
		
			
				|  |  |  |  |         return self.status in SalesOrderStatusGroups.OPEN | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def stock_allocations(self): | 
		
	
		
			
				|  |  |  |  |     def stock_allocations(self) -> QuerySet: | 
		
	
		
			
				|  |  |  |  |         """Return a queryset containing all allocations for this order.""" | 
		
	
		
			
				|  |  |  |  |         return SalesOrderAllocation.objects.filter( | 
		
	
		
			
				|  |  |  |  |             line__in=[line.pk for line in self.lines.all()] | 
		
	
		
			
				|  |  |  |  |         ) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def is_fully_allocated(self): | 
		
	
		
			
				|  |  |  |  |     def is_fully_allocated(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if all line items are fully allocated.""" | 
		
	
		
			
				|  |  |  |  |         return all(line.is_fully_allocated() for line in self.lines.all()) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def is_overallocated(self): | 
		
	
		
			
				|  |  |  |  |     def is_overallocated(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return true if any lines in the order are over-allocated.""" | 
		
	
		
			
				|  |  |  |  |         return any(line.is_overallocated() for line in self.lines.all()) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def is_completed(self): | 
		
	
		
			
				|  |  |  |  |     def is_completed(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Check if this order is "shipped" (all line items delivered).""" | 
		
	
		
			
				|  |  |  |  |         return all(line.is_completed() for line in self.lines.all()) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def can_complete(self, raise_error=False, allow_incomplete_lines=False): | 
		
	
		
			
				|  |  |  |  |     def can_complete( | 
		
	
		
			
				|  |  |  |  |         self, raise_error: bool = False, allow_incomplete_lines: bool = False | 
		
	
		
			
				|  |  |  |  |     ) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Test if this SalesOrder can be completed. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Throws a ValidationError if cannot be completed. | 
		
	
		
			
				|  |  |  |  |         Arguments: | 
		
	
		
			
				|  |  |  |  |             raise_error: If True, raise ValidationError if the order cannot be completed | 
		
	
		
			
				|  |  |  |  |             allow_incomplete_lines: If True, allow incomplete line items when completing the order | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Raises: | 
		
	
		
			
				|  |  |  |  |             ValidationError: If the order cannot be completed, and raise_error is True | 
		
	
		
			
				|  |  |  |  |         """ | 
		
	
		
			
				|  |  |  |  |         try: | 
		
	
		
			
				|  |  |  |  |             if self.status == SalesOrderStatus.COMPLETE.value: | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1424,7 +1431,7 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |         self.issue_order() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def can_issue(self): | 
		
	
		
			
				|  |  |  |  |     def can_issue(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this order can be issued.""" | 
		
	
		
			
				|  |  |  |  |         return self.status in [ | 
		
	
		
			
				|  |  |  |  |             SalesOrderStatus.PENDING.value, | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1450,7 +1457,7 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |             ) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def can_hold(self): | 
		
	
		
			
				|  |  |  |  |     def can_hold(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this order can be placed on hold.""" | 
		
	
		
			
				|  |  |  |  |         return self.status in [ | 
		
	
		
			
				|  |  |  |  |             SalesOrderStatus.PENDING.value, | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1497,7 +1504,7 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |         return True | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def can_cancel(self): | 
		
	
		
			
				|  |  |  |  |     def can_cancel(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this order can be cancelled.""" | 
		
	
		
			
				|  |  |  |  |         return self.is_open | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1579,15 +1586,15 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |     # endregion | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def line_count(self): | 
		
	
		
			
				|  |  |  |  |     def line_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the total number of lines associated with this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.lines.count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def completed_line_items(self): | 
		
	
		
			
				|  |  |  |  |     def completed_line_items(self) -> QuerySet: | 
		
	
		
			
				|  |  |  |  |         """Return a queryset of the completed line items for this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.lines.filter(shipped__gte=F('quantity')) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def pending_line_items(self): | 
		
	
		
			
				|  |  |  |  |     def pending_line_items(self) -> QuerySet: | 
		
	
		
			
				|  |  |  |  |         """Return a queryset of the pending line items for this order. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Note: We exclude "virtual" parts here, as they do not get allocated | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1595,28 +1602,28 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |         return self.lines.filter(shipped__lt=F('quantity')).exclude(part__virtual=True) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def completed_line_count(self): | 
		
	
		
			
				|  |  |  |  |     def completed_line_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the number of completed lines for this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.completed_line_items().count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def pending_line_count(self): | 
		
	
		
			
				|  |  |  |  |     def pending_line_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the number of pending (incomplete) lines associated with this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.pending_line_items().count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def completed_shipments(self): | 
		
	
		
			
				|  |  |  |  |     def completed_shipments(self) -> QuerySet: | 
		
	
		
			
				|  |  |  |  |         """Return a queryset of the completed shipments for this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.shipments.exclude(shipment_date=None) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def pending_shipments(self): | 
		
	
		
			
				|  |  |  |  |     def pending_shipments(self) -> QuerySet: | 
		
	
		
			
				|  |  |  |  |         """Return a queryset of the pending shipments for this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.shipments.filter(shipment_date=None) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def allocations(self): | 
		
	
		
			
				|  |  |  |  |     def allocations(self) -> QuerySet: | 
		
	
		
			
				|  |  |  |  |         """Return a queryset of all allocations for this order.""" | 
		
	
		
			
				|  |  |  |  |         return SalesOrderAllocation.objects.filter(line__order=self) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def pending_allocations(self): | 
		
	
		
			
				|  |  |  |  |     def pending_allocations(self) -> QuerySet: | 
		
	
		
			
				|  |  |  |  |         """Return a queryset of any pending allocations for this order. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Allocations are pending if: | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1630,22 +1637,22 @@ class SalesOrder(TotalPriceMixin, Order): | 
		
	
		
			
				|  |  |  |  |         return self.allocations().filter(Q1 | Q2).distinct() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def shipment_count(self): | 
		
	
		
			
				|  |  |  |  |     def shipment_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the total number of shipments associated with this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.shipments.count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def completed_shipment_count(self): | 
		
	
		
			
				|  |  |  |  |     def completed_shipment_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the number of completed shipments associated with this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.completed_shipments().count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def pending_shipment_count(self): | 
		
	
		
			
				|  |  |  |  |     def pending_shipment_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the number of pending shipments associated with this order.""" | 
		
	
		
			
				|  |  |  |  |         return self.pending_shipments().count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @property | 
		
	
		
			
				|  |  |  |  |     def pending_allocation_count(self): | 
		
	
		
			
				|  |  |  |  |     def pending_allocation_count(self) -> int: | 
		
	
		
			
				|  |  |  |  |         """Return the number of pending (non-shipped) allocations.""" | 
		
	
		
			
				|  |  |  |  |         return self.pending_allocations().count() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1824,14 +1831,17 @@ class PurchaseOrderLineItem(OrderLineItem): | 
		
	
		
			
				|  |  |  |  |     ) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @staticmethod | 
		
	
		
			
				|  |  |  |  |     def get_api_url(): | 
		
	
		
			
				|  |  |  |  |     def get_api_url() -> str: | 
		
	
		
			
				|  |  |  |  |         """Return the API URL associated with the PurchaseOrderLineItem model.""" | 
		
	
		
			
				|  |  |  |  |         return reverse('api-po-line-list') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def clean(self): | 
		
	
		
			
				|  |  |  |  |     def clean(self) -> None: | 
		
	
		
			
				|  |  |  |  |         """Custom clean method for the PurchaseOrderLineItem model. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Ensure the supplier part matches the supplier | 
		
	
		
			
				|  |  |  |  |         Raises: | 
		
	
		
			
				|  |  |  |  |             ValidationError: If the SupplierPart does not match the PurchaseOrder supplier | 
		
	
		
			
				|  |  |  |  |             ValidationError: If the linked BuildOrder is not marked as external | 
		
	
		
			
				|  |  |  |  |             ValidationError: If the linked BuildOrder part does not match the line item part | 
		
	
		
			
				|  |  |  |  |         """ | 
		
	
		
			
				|  |  |  |  |         super().clean() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1963,7 +1973,7 @@ class PurchaseOrderLineItem(OrderLineItem): | 
		
	
		
			
				|  |  |  |  |         """Determine if this line item has been fully received.""" | 
		
	
		
			
				|  |  |  |  |         return self.received >= self.quantity | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def update_pricing(self): | 
		
	
		
			
				|  |  |  |  |     def update_pricing(self) -> None: | 
		
	
		
			
				|  |  |  |  |         """Update pricing information based on the supplier part data.""" | 
		
	
		
			
				|  |  |  |  |         if self.part: | 
		
	
		
			
				|  |  |  |  |             price = self.part.get_price( | 
		
	
	
		
			
				
					
					|  |  |  | @@ -1992,7 +2002,7 @@ class PurchaseOrderExtraLine(OrderExtraLine): | 
		
	
		
			
				|  |  |  |  |         verbose_name = _('Purchase Order Extra Line') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @staticmethod | 
		
	
		
			
				|  |  |  |  |     def get_api_url(): | 
		
	
		
			
				|  |  |  |  |     def get_api_url() -> str: | 
		
	
		
			
				|  |  |  |  |         """Return the API URL associated with the PurchaseOrderExtraLine model.""" | 
		
	
		
			
				|  |  |  |  |         return reverse('api-po-extra-line-list') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -2032,8 +2042,12 @@ class SalesOrderLineItem(OrderLineItem): | 
		
	
		
			
				|  |  |  |  |         """Return the API URL associated with the SalesOrderLineItem model.""" | 
		
	
		
			
				|  |  |  |  |         return reverse('api-so-line-list') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def clean(self): | 
		
	
		
			
				|  |  |  |  |         """Perform extra validation steps for this SalesOrderLineItem instance.""" | 
		
	
		
			
				|  |  |  |  |     def clean(self) -> None: | 
		
	
		
			
				|  |  |  |  |         """Perform extra validation steps for this SalesOrderLineItem instance. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Raises: | 
		
	
		
			
				|  |  |  |  |             ValidationError: If the linked part is not salable | 
		
	
		
			
				|  |  |  |  |         """ | 
		
	
		
			
				|  |  |  |  |         super().clean() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         if self.part: | 
		
	
	
		
			
				
					
					|  |  |  | @@ -2108,7 +2122,7 @@ class SalesOrderLineItem(OrderLineItem): | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         return query['allocated'] | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def is_fully_allocated(self): | 
		
	
		
			
				|  |  |  |  |     def is_fully_allocated(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this line item is fully allocated.""" | 
		
	
		
			
				|  |  |  |  |         # If the linked part is "virtual", then we cannot allocate stock against it | 
		
	
		
			
				|  |  |  |  |         if self.part and self.part.virtual: | 
		
	
	
		
			
				
					
					|  |  |  | @@ -2119,11 +2133,11 @@ class SalesOrderLineItem(OrderLineItem): | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         return self.allocated_quantity() >= self.quantity | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def is_overallocated(self): | 
		
	
		
			
				|  |  |  |  |     def is_overallocated(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this line item is over allocated.""" | 
		
	
		
			
				|  |  |  |  |         return self.allocated_quantity() > self.quantity | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def is_completed(self): | 
		
	
		
			
				|  |  |  |  |     def is_completed(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this line item is completed (has been fully shipped).""" | 
		
	
		
			
				|  |  |  |  |         # A "virtual" part is always considered to be "completed" | 
		
	
		
			
				|  |  |  |  |         if self.part and self.part.virtual: | 
		
	
	
		
			
				
					
					|  |  |  | @@ -2189,8 +2203,12 @@ class SalesOrderShipment( | 
		
	
		
			
				|  |  |  |  |         unique_together = ['order', 'reference'] | 
		
	
		
			
				|  |  |  |  |         verbose_name = _('Sales Order Shipment') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def clean(self): | 
		
	
		
			
				|  |  |  |  |         """Custom clean method for the SalesOrderShipment class.""" | 
		
	
		
			
				|  |  |  |  |     def clean(self) -> None: | 
		
	
		
			
				|  |  |  |  |         """Custom clean method for the SalesOrderShipment class. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Raises: | 
		
	
		
			
				|  |  |  |  |             ValidationError: If the shipment address does not match the customer | 
		
	
		
			
				|  |  |  |  |         """ | 
		
	
		
			
				|  |  |  |  |         super().clean() | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         if self.order and self.shipment_address: | 
		
	
	
		
			
				
					
					|  |  |  | @@ -2200,7 +2218,7 @@ class SalesOrderShipment( | 
		
	
		
			
				|  |  |  |  |                 }) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     @staticmethod | 
		
	
		
			
				|  |  |  |  |     def get_api_url(): | 
		
	
		
			
				|  |  |  |  |     def get_api_url() -> str: | 
		
	
		
			
				|  |  |  |  |         """Return the API URL associated with the SalesOrderShipment model.""" | 
		
	
		
			
				|  |  |  |  |         return reverse('api-so-shipment-list') | 
		
	
		
			
				|  |  |  |  |  | 
		
	
	
		
			
				
					
					|  |  |  | @@ -2299,16 +2317,24 @@ class SalesOrderShipment( | 
		
	
		
			
				|  |  |  |  |         """ | 
		
	
		
			
				|  |  |  |  |         return self.shipment_address or self.order.address | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def is_complete(self): | 
		
	
		
			
				|  |  |  |  |     def is_checked(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this shipment has been checked.""" | 
		
	
		
			
				|  |  |  |  |         return self.checked_by is not None | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def is_complete(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this shipment has already been completed.""" | 
		
	
		
			
				|  |  |  |  |         return self.shipment_date is not None | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def is_delivered(self): | 
		
	
		
			
				|  |  |  |  |     def is_delivered(self) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Return True if this shipment has already been delivered.""" | 
		
	
		
			
				|  |  |  |  |         return self.delivery_date is not None | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |     def check_can_complete(self, raise_error=True): | 
		
	
		
			
				|  |  |  |  |         """Check if this shipment is able to be completed.""" | 
		
	
		
			
				|  |  |  |  |     def check_can_complete(self, raise_error: bool = True) -> bool: | 
		
	
		
			
				|  |  |  |  |         """Check if this shipment is able to be completed. | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         Arguments: | 
		
	
		
			
				|  |  |  |  |             raise_error: If True, raise ValidationError if cannot complete | 
		
	
		
			
				|  |  |  |  |         """ | 
		
	
		
			
				|  |  |  |  |         try: | 
		
	
		
			
				|  |  |  |  |             if self.shipment_date: | 
		
	
		
			
				|  |  |  |  |                 # Shipment has already been sent! | 
		
	
	
		
			
				
					
					|  |  |  | @@ -2317,6 +2343,14 @@ class SalesOrderShipment( | 
		
	
		
			
				|  |  |  |  |             if self.allocations.count() == 0: | 
		
	
		
			
				|  |  |  |  |                 raise ValidationError(_('Shipment has no allocated stock items')) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |             if ( | 
		
	
		
			
				|  |  |  |  |                 get_global_setting('SALESORDER_SHIPMENT_REQUIRES_CHECK') | 
		
	
		
			
				|  |  |  |  |                 and not self.is_checked() | 
		
	
		
			
				|  |  |  |  |             ): | 
		
	
		
			
				|  |  |  |  |                 raise ValidationError( | 
		
	
		
			
				|  |  |  |  |                     _('Shipment must be checked before it can be completed') | 
		
	
		
			
				|  |  |  |  |                 ) | 
		
	
		
			
				|  |  |  |  |  | 
		
	
		
			
				|  |  |  |  |         except ValidationError as e: | 
		
	
		
			
				|  |  |  |  |             if raise_error: | 
		
	
		
			
				|  |  |  |  |                 raise e | 
		
	
	
		
			
				
					
					|  |  |  |   |