2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 22:21:37 +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

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