2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-28 20:07:39 +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:
Oliver
2025-09-03 15:13:08 +10:00
committed by GitHub
parent 41cc0850c6
commit 7969d2d9ce
14 changed files with 195 additions and 42 deletions

View File

@@ -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

39
docs/docs/part/virtual.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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)',

View File

@@ -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

View File

@@ -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",
),
),
]

View File

@@ -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

View File

@@ -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'
)

View File

@@ -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:

View File

@@ -103,6 +103,7 @@ export function useStockFields({
value: partId || partInstance?.pk,
disabled: !create,
filters: {
virtual: false,
active: create ? true : undefined
},
onValueChange: (value, record) => {

View File

@@ -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,
{

View File

@@ -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`,

View File

@@ -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 }) => {