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:
@@ -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:
|
||||
|
Reference in New Issue
Block a user