From 7969d2d9ce6b27db2ec24d62059d34052977aa1f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Sep 2025 15:13:08 +1000 Subject: [PATCH] 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 --- docs/docs/part/index.md | 2 +- docs/docs/part/virtual.md | 39 ++++++++++++ docs/mkdocs.yml | 1 + .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/build/models.py | 3 +- src/backend/InvenTree/build/tasks.py | 12 +++- .../0112_alter_salesorderlineitem_part.py | 28 +++++++++ src/backend/InvenTree/order/models.py | 22 ++++--- src/backend/InvenTree/part/api.py | 4 ++ src/backend/InvenTree/part/models.py | 25 ++++++-- src/frontend/src/forms/StockForms.tsx | 1 + src/frontend/src/pages/part/PartDetail.tsx | 22 +++++-- src/frontend/src/tables/bom/BomTable.tsx | 10 +++ .../tables/sales/SalesOrderLineItemTable.tsx | 62 +++++++++++++------ 14 files changed, 195 insertions(+), 42 deletions(-) create mode 100644 docs/docs/part/virtual.md create mode 100644 src/backend/InvenTree/order/migrations/0112_alter_salesorderlineitem_part.py diff --git a/docs/docs/part/index.md b/docs/docs/part/index.md index c14ec788d6..e4848ece1f 100644 --- a/docs/docs/part/index.md +++ b/docs/docs/part/index.md @@ -31,7 +31,7 @@ Each *Part* defined in the database provides a number of different attributes wh ### Virtual -A *Virtual* part is one which does not physically exist but should still be tracked in the system. This could be a process step, machine time, software license, etc. +A *Virtual* part is one which does not physically exist but should still be tracked in the system. This could be a process step, machine time, software license, etc. Refer to the [virtual parts documentation](./virtual.md) for more information. ### Template diff --git a/docs/docs/part/virtual.md b/docs/docs/part/virtual.md new file mode 100644 index 0000000000..ad55f7f270 --- /dev/null +++ b/docs/docs/part/virtual.md @@ -0,0 +1,39 @@ +--- +title: Virtual Parts +--- + +## Virtual Parts + +A *Virtual* part can be used to represent non-physical items in the InvenTree system. Virtual parts cannot have stock items associated with them, as they not physically exist. + +Virtual parts can be used to represent things such as: + +- Software licenses +- Labor costs +- Process steps + +Apart from the fact that virtual parts cannot have stock items, they behave in the same way as regular parts. + +### Stock Items + +Virtual parts cannot have stock items associated with them. User interface elements related to stock items are hidden when viewing a virtual part. + +### Bills of Material + +Virtual parts can be added as a subcomponent to the [Bills of Material](../manufacturing/bom.md) for an assembled part. This can be useful to represent labor costs, or other non-physical components which are required to build an assembly. + +Even though the virtual parts are not allocated during the build process, they are still listed in the BOM and can be included in cost calculations. + +### Build Orders + +When creating a [Build Order](../manufacturing/build.md) for an assembly which includes virtual parts in its BOM, the virtual parts will be hidden from the list of required parts. This is because virtual parts do not need to be allocated during the build process. + +The parts are still available in the BOM, and the cost of the virtual parts will be included in the total cost of the build. + +### Sales Orders + +Virtual parts can be added to [Sales Orders](../sales/sales_order.md) in the same way as regular parts. This can be useful to represent services, or other non-physical items which are being sold. + +When a sales order is fulfilled, virtual parts will not be allocated from stock, but they will be included in the order and the total cost. + +Virtual parts do not need to be allocated during the fulfillment process, as they do not physically exist. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c4e1f8ed20..3f2b9591e4 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -145,6 +145,7 @@ nav: - Parts: - Parts: part/index.md - Creating Parts: part/create.md + - Virtual Parts: part/virtual.md - Part Views: part/views.md - Tracking: part/trackable.md - Parameters: part/parameter.md diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index ecbce4152a..c2cfe4f797 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index cdfe725543..5b6b00f46e 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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)', diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 5ce4798a8d..4c6625741c 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -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 diff --git a/src/backend/InvenTree/order/migrations/0112_alter_salesorderlineitem_part.py b/src/backend/InvenTree/order/migrations/0112_alter_salesorderlineitem_part.py new file mode 100644 index 0000000000..6151608ff0 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0112_alter_salesorderlineitem_part.py @@ -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", + ), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 4a6cb4e6c9..d6e7de2300 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -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 diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index cd40a05764..94a1dd6c65 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -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' ) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index bade05e572..446f12d743 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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: diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 3df3dd8543..9da55a945c 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -103,6 +103,7 @@ export function useStockFields({ value: partId || partInstance?.pk, disabled: !create, filters: { + virtual: false, active: create ? true : undefined }, onValueChange: (value, record) => { diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 70d048dcd0..9fe8174b41 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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: , + hidden: part.virtual || !user.hasViewRole(UserRoles.stock), content: part.pk ? ( , - hidden: !part.component && !part.salable, + hidden: (!part.component && !part.salable) || part.virtual, content: part.pk ? : }, { @@ -915,6 +917,8 @@ export default function PartDetail() { ), 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' />, , , , + ]; }, [partRequirements, partRequirementsQuery.isFetching, part]); @@ -1143,6 +1154,7 @@ export default function PartDetail() { } + hidden={part.virtual || !user.hasViewRole(UserRoles.stock)} actions={[ ...stockAdjustActions.menuActions, { diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx index 403a015b03..94e9dd81a4 100644 --- a/src/frontend/src/tables/bom/BomTable.tsx +++ b/src/frontend/src/tables/bom/BomTable.tsx @@ -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`, diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 70ce599e48..d3ef56c844 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -82,10 +82,12 @@ export default function SalesOrderLineItemTable({ render: (record: any) => { return ( - + {record.part_detail?.virtual || ( + + )} ); @@ -133,6 +135,10 @@ export default function SalesOrderLineItemTable({ accessor: 'stock', title: t`Available Stock`, render: (record: any) => { + if (record.part_detail?.virtual) { + return {t`Virtual part`}; + } + 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) => ( - - ) + render: (record: any) => { + if (record.part_detail?.virtual) { + return {t`Virtual part`}; + } + + return ( + + ); + } }, { accessor: 'shipped', sortable: true, - render: (record: any) => ( - - ) + render: (record: any) => { + if (record.part_detail?.virtual) { + return {t`Virtual part`}; + } + + return ( + + ); + } }, { 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 }) => {