mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Virtual parts enhancements (#10257)
* Prevent virtual parts from being linked in a BuildOrder * Hide "stock" tab for virtual parts * Filter out virtual parts when creating a new stock item * Support virtual parts in sales orders * Add 'virtual' filter for BomItem * Hide stock badges for virtual parts * Tweak PartDetail page * docs * Adjust completion logic for SalesOrder * Fix backend filter * Remove restriction * Adjust table * Fix for "pending_line_items" * Hide more panels for "Virtual" part * Add badge for "virtual" part * Bump API version * Fix docs link
This commit is contained in:
		| @@ -1,11 +1,15 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 389 | ||||
| INVENTREE_API_VERSION = 390 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
| v390 -> 2025-09-03 : https://github.com/inventree/InvenTree/pull/10257 | ||||
|     - Fixes limitation on adding virtual parts to a SalesOrder | ||||
|     - Additional query filter options for BomItem API endpoint | ||||
|  | ||||
| v389 -> 2025-08-27 : https://github.com/inventree/InvenTree/pull/10214 | ||||
|     - Adds "output" filter to the BuildItem API endpoint | ||||
|     - Removes undocumented 'output' query parameter handling | ||||
|   | ||||
| @@ -1461,7 +1461,8 @@ class Build( | ||||
|         """Create BuildLine objects for each BOM line in this BuildOrder.""" | ||||
|         lines = [] | ||||
|  | ||||
|         bom_items = self.part.get_bom_items() | ||||
|         # Find all non-virtual BOM items for the parent part | ||||
|         bom_items = self.part.get_bom_items(include_virtual=False) | ||||
|  | ||||
|         logger.info( | ||||
|             'Creating BuildLine objects for BuildOrder %s (%s items)', | ||||
|   | ||||
| @@ -100,12 +100,17 @@ def update_build_order_lines(bom_item_pk: int): | ||||
|         q = bom_item.get_required_quantity(bo.quantity) | ||||
|  | ||||
|         if line: | ||||
|             # If the BOM item points to a "virtual" part, delete the BuildLine instance | ||||
|             if bom_item.sub_part.virtual: | ||||
|                 line.delete() | ||||
|                 continue | ||||
|  | ||||
|             # Ensure quantity is correct | ||||
|             if line.quantity != q: | ||||
|                 line.quantity = q | ||||
|                 line.save() | ||||
|         else: | ||||
|             # Create a new line item | ||||
|         elif not bom_item.sub_part.virtual: | ||||
|             # Create a new line item (for non-virtual parts) | ||||
|             BuildLine.objects.create(build=bo, bom_item=bom_item, quantity=q) | ||||
|  | ||||
|     if builds.count() > 0: | ||||
| @@ -141,7 +146,8 @@ def check_build_stock(build): | ||||
|         logger.exception("Invalid build.part passed to 'build.tasks.check_build_stock'") | ||||
|         return | ||||
|  | ||||
|     for bom_item in part.get_bom_items(): | ||||
|     # Iterate through each non-virtual BOM item for this part | ||||
|     for bom_item in part.get_bom_items(include_virtual=False): | ||||
|         sub_part = bom_item.sub_part | ||||
|  | ||||
|         # The 'in stock' quantity depends on whether the bom_item allows variants | ||||
|   | ||||
| @@ -0,0 +1,28 @@ | ||||
| # Generated by Django 4.2.23 on 2025-09-03 04:13 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("part", "0142_remove_part_last_stocktake_remove_partstocktake_note_and_more"), | ||||
|         ("order", "0111_purchaseorderlineitem_build_order"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="salesorderlineitem", | ||||
|             name="part", | ||||
|             field=models.ForeignKey( | ||||
|                 help_text="Part", | ||||
|                 limit_choices_to={"salable": True}, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="sales_order_line_items", | ||||
|                 to="part.part", | ||||
|                 verbose_name="Part", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1581,8 +1581,11 @@ class SalesOrder(TotalPriceMixin, Order): | ||||
|         return self.lines.filter(shipped__gte=F('quantity')) | ||||
|  | ||||
|     def pending_line_items(self): | ||||
|         """Return a queryset of the pending line items for this order.""" | ||||
|         return self.lines.filter(shipped__lt=F('quantity')) | ||||
|         """Return a queryset of the pending line items for this order. | ||||
|  | ||||
|         Note: We exclude "virtual" parts here, as they do not get allocated | ||||
|         """ | ||||
|         return self.lines.filter(shipped__lt=F('quantity')).exclude(part__virtual=True) | ||||
|  | ||||
|     @property | ||||
|     def completed_line_count(self): | ||||
| @@ -2027,11 +2030,6 @@ class SalesOrderLineItem(OrderLineItem): | ||||
|         super().clean() | ||||
|  | ||||
|         if self.part: | ||||
|             if self.part.virtual: | ||||
|                 raise ValidationError({ | ||||
|                     'part': _('Virtual part cannot be assigned to a sales order') | ||||
|                 }) | ||||
|  | ||||
|             if not self.part.salable: | ||||
|                 raise ValidationError({ | ||||
|                     'part': _('Only salable parts can be assigned to a sales order') | ||||
| @@ -2052,7 +2050,7 @@ class SalesOrderLineItem(OrderLineItem): | ||||
|         null=True, | ||||
|         verbose_name=_('Part'), | ||||
|         help_text=_('Part'), | ||||
|         limit_choices_to={'salable': True, 'virtual': False}, | ||||
|         limit_choices_to={'salable': True}, | ||||
|     ) | ||||
|  | ||||
|     sale_price = InvenTreeModelMoneyField( | ||||
| @@ -2105,6 +2103,10 @@ class SalesOrderLineItem(OrderLineItem): | ||||
|  | ||||
|     def is_fully_allocated(self): | ||||
|         """Return True if this line item is fully allocated.""" | ||||
|         # If the linked part is "virtual", then we cannot allocate stock against it | ||||
|         if self.part and self.part.virtual: | ||||
|             return True | ||||
|  | ||||
|         if self.order.status == SalesOrderStatus.SHIPPED: | ||||
|             return self.fulfilled_quantity() >= self.quantity | ||||
|  | ||||
| @@ -2116,6 +2118,10 @@ class SalesOrderLineItem(OrderLineItem): | ||||
|  | ||||
|     def is_completed(self): | ||||
|         """Return True if this line item is completed (has been fully shipped).""" | ||||
|         # A "virtual" part is always considered to be "completed" | ||||
|         if self.part and self.part.virtual: | ||||
|             return True | ||||
|  | ||||
|         return self.shipped >= self.quantity | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1515,6 +1515,10 @@ class BomFilter(rest_filters.FilterSet): | ||||
|         label='Component part is an assembly', field_name='sub_part__assembly' | ||||
|     ) | ||||
|  | ||||
|     sub_part_virtual = rest_filters.BooleanFilter( | ||||
|         label='Component part is virtual', field_name='sub_part__virtual' | ||||
|     ) | ||||
|  | ||||
|     available_stock = rest_filters.BooleanFilter( | ||||
|         label='Has available stock', method='filter_available_stock' | ||||
|     ) | ||||
|   | ||||
| @@ -1796,9 +1796,15 @@ class Part( | ||||
|         """ | ||||
|         return self.get_stock_count(include_variants=True) | ||||
|  | ||||
|     def get_bom_item_filter(self, include_inherited=True): | ||||
|     def get_bom_item_filter( | ||||
|         self, include_inherited: bool = True, include_virtual: bool = True | ||||
|     ): | ||||
|         """Returns a query filter for all BOM items associated with this Part. | ||||
|  | ||||
|         Arguments: | ||||
|             include_inherited: If True, include BomItem entries defined for parent parts | ||||
|             include_virtual: If True, include BomItem entries which are virtual | ||||
|  | ||||
|         There are some considerations: | ||||
|  | ||||
|         a) BOM items can be defined against *this* part | ||||
| @@ -1824,15 +1830,24 @@ class Part( | ||||
|                 # OR the filters together | ||||
|                 bom_filter |= parent_filter | ||||
|  | ||||
|         if not include_virtual: | ||||
|             bom_filter &= Q(sub_part__virtual=False) | ||||
|  | ||||
|         return bom_filter | ||||
|  | ||||
|     def get_bom_items(self, include_inherited=True) -> QuerySet[BomItem]: | ||||
|     def get_bom_items( | ||||
|         self, include_inherited: bool = True, include_virtual: bool = True | ||||
|     ) -> QuerySet[BomItem]: | ||||
|         """Return a queryset containing all BOM items for this part. | ||||
|  | ||||
|         By default, will include inherited BOM items | ||||
|         Arguments: | ||||
|             include_inherited (bool): If set, include BomItem entries defined for parent parts | ||||
|             include_virtual (bool): If set, include BomItem entries which are virtual parts | ||||
|         """ | ||||
|         queryset = BomItem.objects.filter( | ||||
|             self.get_bom_item_filter(include_inherited=include_inherited) | ||||
|             self.get_bom_item_filter( | ||||
|                 include_inherited=include_inherited, include_virtual=include_virtual | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|         return queryset.prefetch_related('part', 'sub_part') | ||||
| @@ -2332,7 +2347,7 @@ class Part( | ||||
|         return None | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def copy_bom_from(self, other, clear=True, **kwargs): | ||||
|     def copy_bom_from(self, other, clear: bool = True, **kwargs): | ||||
|         """Copy the BOM from another part. | ||||
|  | ||||
|         Args: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user