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: