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: | ||||
|   | ||||
| @@ -103,6 +103,7 @@ export function useStockFields({ | ||||
|         value: partId || partInstance?.pk, | ||||
|         disabled: !create, | ||||
|         filters: { | ||||
|           virtual: false, | ||||
|           active: create ? true : undefined | ||||
|         }, | ||||
|         onValueChange: (value, record) => { | ||||
|   | ||||
| @@ -532,7 +532,8 @@ export default function PartDetail() { | ||||
|         type: 'number', | ||||
|         name: 'total_in_stock', | ||||
|         unit: part.units, | ||||
|         label: t`In Stock` | ||||
|         label: t`In Stock`, | ||||
|         hidden: part.virtual | ||||
|       }, | ||||
|       { | ||||
|         type: 'progressbar', | ||||
| @@ -540,7 +541,7 @@ export default function PartDetail() { | ||||
|         total: data.total_in_stock, | ||||
|         progress: data.unallocated, | ||||
|         label: t`Available Stock`, | ||||
|         hidden: data.total_in_stock == data.unallocated | ||||
|         hidden: part.virtual || data.total_in_stock == data.unallocated | ||||
|       }, | ||||
|       { | ||||
|         type: 'number', | ||||
| @@ -803,6 +804,7 @@ export default function PartDetail() { | ||||
|         name: 'stock', | ||||
|         label: t`Stock`, | ||||
|         icon: <IconPackages />, | ||||
|         hidden: part.virtual || !user.hasViewRole(UserRoles.stock), | ||||
|         content: part.pk ? ( | ||||
|           <StockItemTable | ||||
|             tableName='part-stock' | ||||
| @@ -828,7 +830,7 @@ export default function PartDetail() { | ||||
|         name: 'allocations', | ||||
|         label: t`Allocations`, | ||||
|         icon: <IconBookmarks />, | ||||
|         hidden: !part.component && !part.salable, | ||||
|         hidden: (!part.component && !part.salable) || part.virtual, | ||||
|         content: part.pk ? <PartAllocationPanel part={part} /> : <Skeleton /> | ||||
|       }, | ||||
|       { | ||||
| @@ -915,6 +917,8 @@ export default function PartDetail() { | ||||
|           <Skeleton /> | ||||
|         ), | ||||
|         hidden: | ||||
|           part.virtual || | ||||
|           !user.hasViewRole(UserRoles.stock) || | ||||
|           !globalSettings.isSet('STOCKTAKE_ENABLE') || | ||||
|           !userSettings.isSet('DISPLAY_STOCKTAKE_TAB') | ||||
|       }, | ||||
| @@ -973,7 +977,7 @@ export default function PartDetail() { | ||||
|             ? 'green' | ||||
|             : 'orange' | ||||
|         } | ||||
|         visible={partRequirements.total_stock > 0} | ||||
|         visible={!part.virtual && partRequirements.total_stock > 0} | ||||
|         key='in_stock' | ||||
|       />, | ||||
|       <DetailsBadge | ||||
| @@ -981,13 +985,14 @@ export default function PartDetail() { | ||||
|         color='yellow' | ||||
|         key='available_stock' | ||||
|         visible={ | ||||
|           !part.virtual && | ||||
|           partRequirements.unallocated_stock != partRequirements.total_stock | ||||
|         } | ||||
|       />, | ||||
|       <DetailsBadge | ||||
|         label={t`No Stock`} | ||||
|         color='orange' | ||||
|         visible={partRequirements.total_stock == 0} | ||||
|         visible={!part.virtual && partRequirements.total_stock == 0} | ||||
|         key='no_stock' | ||||
|       />, | ||||
|       <DetailsBadge | ||||
| @@ -1013,6 +1018,12 @@ export default function PartDetail() { | ||||
|         color='red' | ||||
|         visible={!part.active} | ||||
|         key='inactive' | ||||
|       />, | ||||
|       <DetailsBadge | ||||
|         label={t`Virtual Part`} | ||||
|         color='cyan.4' | ||||
|         visible={part.virtual} | ||||
|         key='virtual' | ||||
|       /> | ||||
|     ]; | ||||
|   }, [partRequirements, partRequirementsQuery.isFetching, part]); | ||||
| @@ -1143,6 +1154,7 @@ export default function PartDetail() { | ||||
|       <ActionDropdown | ||||
|         tooltip={t`Stock Actions`} | ||||
|         icon={<IconPackages />} | ||||
|         hidden={part.virtual || !user.hasViewRole(UserRoles.stock)} | ||||
|         actions={[ | ||||
|           ...stockAdjustActions.menuActions, | ||||
|           { | ||||
|   | ||||
| @@ -137,6 +137,11 @@ export function BomTable({ | ||||
|       DescriptionColumn({ | ||||
|         accessor: 'sub_part_detail.description' | ||||
|       }), | ||||
|       BooleanColumn({ | ||||
|         accessor: 'sub_part_detail.virtual', | ||||
|         defaultVisible: false, | ||||
|         title: t`Virtual Part` | ||||
|       }), | ||||
|       ReferenceColumn({ | ||||
|         switchable: true | ||||
|       }), | ||||
| @@ -404,6 +409,11 @@ export function BomTable({ | ||||
|         label: t`Assembled Part`, | ||||
|         description: t`Show assembled items` | ||||
|       }, | ||||
|       { | ||||
|         name: 'sub_part_virtual', | ||||
|         label: t`Virtual Part`, | ||||
|         description: t`Show virtual items` | ||||
|       }, | ||||
|       { | ||||
|         name: 'available_stock', | ||||
|         label: t`Available Stock`, | ||||
|   | ||||
| @@ -82,10 +82,12 @@ export default function SalesOrderLineItemTable({ | ||||
|         render: (record: any) => { | ||||
|           return ( | ||||
|             <Group wrap='nowrap'> | ||||
|               <RowExpansionIcon | ||||
|                 enabled={record.allocated} | ||||
|                 expanded={table.isRowExpanded(record.pk)} | ||||
|               /> | ||||
|               {record.part_detail?.virtual || ( | ||||
|                 <RowExpansionIcon | ||||
|                   enabled={record.allocated} | ||||
|                   expanded={table.isRowExpanded(record.pk)} | ||||
|                 /> | ||||
|               )} | ||||
|               <RenderPartColumn part={record.part_detail} /> | ||||
|             </Group> | ||||
|           ); | ||||
| @@ -133,6 +135,10 @@ export default function SalesOrderLineItemTable({ | ||||
|         accessor: 'stock', | ||||
|         title: t`Available Stock`, | ||||
|         render: (record: any) => { | ||||
|           if (record.part_detail?.virtual) { | ||||
|             return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>; | ||||
|           } | ||||
|  | ||||
|           const part_stock = record?.available_stock ?? 0; | ||||
|           const variant_stock = record?.available_variant_stock ?? 0; | ||||
|           const available = part_stock + variant_stock; | ||||
| @@ -186,24 +192,36 @@ export default function SalesOrderLineItemTable({ | ||||
|       { | ||||
|         accessor: 'allocated', | ||||
|         sortable: true, | ||||
|         render: (record: any) => ( | ||||
|           <ProgressBar | ||||
|             progressLabel={true} | ||||
|             value={record.allocated} | ||||
|             maximum={record.quantity} | ||||
|           /> | ||||
|         ) | ||||
|         render: (record: any) => { | ||||
|           if (record.part_detail?.virtual) { | ||||
|             return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>; | ||||
|           } | ||||
|  | ||||
|           return ( | ||||
|             <ProgressBar | ||||
|               progressLabel={true} | ||||
|               value={record.allocated} | ||||
|               maximum={record.quantity} | ||||
|             /> | ||||
|           ); | ||||
|         } | ||||
|       }, | ||||
|       { | ||||
|         accessor: 'shipped', | ||||
|         sortable: true, | ||||
|         render: (record: any) => ( | ||||
|           <ProgressBar | ||||
|             progressLabel={true} | ||||
|             value={record.shipped} | ||||
|             maximum={record.quantity} | ||||
|           /> | ||||
|         ) | ||||
|         render: (record: any) => { | ||||
|           if (record.part_detail?.virtual) { | ||||
|             return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>; | ||||
|           } | ||||
|  | ||||
|           return ( | ||||
|             <ProgressBar | ||||
|               progressLabel={true} | ||||
|               value={record.shipped} | ||||
|               maximum={record.quantity} | ||||
|             /> | ||||
|           ); | ||||
|         } | ||||
|       }, | ||||
|       { | ||||
|         accessor: 'notes' | ||||
| @@ -371,6 +389,7 @@ export default function SalesOrderLineItemTable({ | ||||
|   const rowActions = useCallback( | ||||
|     (record: any): RowAction[] => { | ||||
|       const allocated = (record?.allocated ?? 0) > (record?.quantity ?? 0); | ||||
|       const virtual = record?.part_detail?.virtual ?? false; | ||||
|  | ||||
|       return [ | ||||
|         RowViewAction({ | ||||
| @@ -383,6 +402,7 @@ export default function SalesOrderLineItemTable({ | ||||
|         { | ||||
|           hidden: | ||||
|             allocated || | ||||
|             virtual || | ||||
|             !editable || | ||||
|             !user.hasChangeRole(UserRoles.sales_order), | ||||
|           title: t`Allocate Stock`, | ||||
| @@ -397,6 +417,7 @@ export default function SalesOrderLineItemTable({ | ||||
|           hidden: | ||||
|             !record?.part_detail?.trackable || | ||||
|             allocated || | ||||
|             virtual || | ||||
|             !editable || | ||||
|             !user.hasChangeRole(UserRoles.sales_order), | ||||
|           title: t`Allocate serials`, | ||||
| @@ -414,6 +435,7 @@ export default function SalesOrderLineItemTable({ | ||||
|         { | ||||
|           hidden: | ||||
|             allocated || | ||||
|             virtual || | ||||
|             !user.hasAddRole(UserRoles.build) || | ||||
|             !record?.part_detail?.assembly, | ||||
|           title: t`Build stock`, | ||||
| @@ -431,6 +453,7 @@ export default function SalesOrderLineItemTable({ | ||||
|         { | ||||
|           hidden: | ||||
|             allocated || | ||||
|             virtual || | ||||
|             !user.hasAddRole(UserRoles.purchase_order) || | ||||
|             !record?.part_detail?.purchaseable, | ||||
|           title: t`Order stock`, | ||||
| @@ -472,6 +495,9 @@ export default function SalesOrderLineItemTable({ | ||||
|     return { | ||||
|       allowMultiple: true, | ||||
|       expandable: ({ record }: { record: any }) => { | ||||
|         if (record?.part_detail?.virtual) { | ||||
|           return false; | ||||
|         } | ||||
|         return table.isRowExpanded(record.pk) || record.allocated > 0; | ||||
|       }, | ||||
|       content: ({ record }: { record: any }) => { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user