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:
|
||||
|
@@ -103,6 +103,7 @@ export function useStockFields({
|
||||
value: partId || partInstance?.pk,
|
||||
disabled: !create,
|
||||
filters: {
|
||||
virtual: false,
|
||||
active: create ? true : undefined
|
||||
},
|
||||
onValueChange: (value, record) => {
|
||||
|
@@ -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,
|
||||
{
|
||||
|
@@ -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`,
|
||||
|
@@ -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 }) => {
|
||||
|
Reference in New Issue
Block a user