This commit is contained in:
Oliver 2026-07-03 14:14:23 +00:00 committed by GitHub
commit 98e544f725
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 365 additions and 21 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#12295](https://github.com/inventree/InvenTree/pull/12295) adds "consumable" field to the Part model and API endpoints
- [#12250](https://github.com/inventree/InvenTree/pull/12250) adds "active" field to the ProjectCode model and API endpoints
### Changed

View File

@ -50,6 +50,8 @@ In the example below, see that the *Wood Screw* line item is marked as consumabl
Further, in the [Build Order](./build.md) stock allocation table, we see that this line item cannot be allocated, as it is *consumable*.
Marking a BOM line item as consumable is a per-assembly override - it only affects how the part is treated within *this* particular BOM, and does not change the underlying part definition. A part can also be marked as consumable directly - refer to the [consumable parts documentation](../part/consumable.md) for more information on the distinction between the two, and reasons why a part may be marked as consumable.
### Optional BOM Line Items
If a BOM line item is marked as *optional*, this means that the part and quantity information is tracked in the BOM, but this line item is not required to be allocated to a [Build Order](./build.md). This may be useful for certain items which are not strictly required for the build process to be completed.

View File

@ -0,0 +1,55 @@
---
title: Consumable Parts
---
## Consumable Parts
A *Consumable* part is one which is generally used up, or expended, during a build or other process, rather than being tracked and allocated as a discrete item.
Consumable parts can be used to represent things such as:
- Glue or adhesive
- Solder
- Cleaning fluid
- Fasteners or other low-value hardware
- Other general workshop supplies
Marking a part as consumable is intended to help distinguish these types of supplies from other parts in the inventory, making it easier to filter and report on "real" stock items versus general consumables.
### Why Mark a Part as Consumable?
Not every item required to complete a build needs to be tracked and allocated with the same rigor as a high-value component. A part might be marked as consumable because it is:
- Low value, making the overhead of precise allocation not worth the effort
- Kept in abundant stock, such that running out is unlikely to be a concern
- Difficult to track in discrete units, such as a fluid or adhesive
- Not something the business needs (or wants) visibility into at the build order level
Marking a part as consumable communicates this intent clearly wherever the part is used, without needing to remember to configure each individual BOM line item.
### Stock Items
Consumable parts can have stock items associated with them, the same as any other part. Stock levels for consumable parts can be tracked and managed as normal.
### Bills of Material
Consumable parts can be added as a subcomponent to the [Bills of Material](../manufacturing/bom.md) for an assembled part, in the same way as any other component.
### Build Orders
Unlike a regular component, a consumable part is not allocated to (or consumed by) a [Build Order](../manufacturing/build.md). When a build order is completed, stock quantities for consumable parts are not adjusted.
If stock levels for a consumable part need to be updated to reflect usage during a build, this must be done manually.
### Consumable BOM Line Items
Separately from the *Consumable* flag on the part itself, an individual [BOM line item](../manufacturing/bom.md#consumable-bom-line-items) can also be marked as *consumable*.
This allows a part which is **not** normally marked as consumable to be treated as consumable *for the purposes of a specific BOM*. For example, a fastener which is usually tracked precisely in one assembly might be treated as consumable in another assembly, without needing to change the underlying part definition.
In other words:
- The part-level *Consumable* flag is a permanent property of the part, and applies everywhere that part is used.
- The BOM line item *Consumable* flag is a per-assembly override, and only affects how that part is treated within that particular BOM.
If a part is already marked as consumable, marking the corresponding BOM line item as consumable as well has no additional effect.

View File

@ -87,6 +87,9 @@ A [Purchase Order](../purchasing/purchase_order.md) allows parts to be ordered f
If a part is designated as *Salable* it can be sold to external customers. Setting this flag allows parts to be added to sales orders.
### Consumable
A *Consumable* part is one which is generally used up during a build or other process, rather than being tracked and allocated as a discrete item. Refer to the [consumable parts documentation](./consumable.md) for more information.
## Locked Parts

View File

@ -130,6 +130,7 @@ nav:
- Parts: part/index.md
- Creating Parts: part/create.md
- Virtual Parts: part/virtual.md
- Consumable Parts: part/consumable.md
- Part Views: part/views.md
- Tracking: part/trackable.md
- Revisions: part/revision.md

View File

@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 514
INVENTREE_API_VERSION = 515
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v515 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12295
- Adds "consumable" field to the Part model
v514 -> 2026-07-02 : https://github.com/inventree/InvenTree/pull/12294
- Adds "duplicate" field to the BuildOrder, Company, ManufacturerPart, SupplierPart and SalesOrderShipment API endpoints
- Order duplication options: renames "order_id" field to "original", which now performs primary-key validation

View File

@ -457,8 +457,21 @@ class BuildLineFilter(FilterSet):
# Fields on related models
consumable = rest_filters.BooleanFilter(
label=_('Consumable'), field_name='bom_item__consumable'
label=_('Consumable'), method='filter_consumable'
)
def filter_consumable(self, queryset, name, value):
"""Filter the queryset based on the "effective" consumable status of the BOM item.
A BuildLine is considered "consumable" if either the BOM item itself,
or the underlying part, is marked as consumable.
"""
return queryset.filter(
part_models.BomItem.consumable_filter(
consumable=str2bool(value), prefix='bom_item__'
)
)
optional = rest_filters.BooleanFilter(
label=_('Optional'), field_name='bom_item__optional'
)

View File

@ -974,7 +974,9 @@ class Build(
items_to_delete = []
lines = self.untracked_line_items.all()
lines = lines.exclude(bom_item__consumable=True)
lines = lines.exclude(
part.models.BomItem.consumable_filter(prefix='bom_item__')
)
lines = lines.annotate(allocated=annotate_allocated_quantity())
for build_line in lines:
@ -1254,13 +1256,16 @@ class Build(
return allocations
tracked_line_items = self.tracked_line_items.filter(
bom_item__consumable=False, bom_item__sub_part__virtual=False
part.models.BomItem.consumable_filter(
consumable=False, prefix='bom_item__'
),
bom_item__sub_part__virtual=False,
)
for line_item in tracked_line_items:
bom_item = line_item.bom_item
if bom_item.consumable:
if bom_item.is_consumable:
# Do not auto-allocate stock to consumable BOM items
continue
@ -1384,7 +1389,7 @@ class Build(
# Find the referenced BomItem
bom_item = line_item.bom_item
if bom_item.consumable:
if bom_item.is_consumable:
# Do not auto-allocate stock to consumable BOM items
continue
@ -1504,7 +1509,9 @@ class Build(
lines = self.build_lines.all()
# Remove any 'consumable' line items
lines = lines.exclude(bom_item__consumable=True)
lines = lines.exclude(
part.models.BomItem.consumable_filter(prefix='bom_item__')
)
if tracked is True:
lines = lines.filter(bom_item__sub_part__trackable=True)
@ -1541,7 +1548,9 @@ class Build(
we need to test all "trackable" BuildLine objects
"""
lines = self.build_lines.filter(bom_item__sub_part__trackable=True)
lines = lines.exclude(bom_item__consumable=True)
lines = lines.exclude(
part.models.BomItem.consumable_filter(prefix='bom_item__')
)
# Find any lines which have not been fully allocated
for line in lines:
@ -1565,7 +1574,9 @@ class Build(
Returns:
True if any BuildLine has been over-allocated.
"""
lines = self.build_lines.all().exclude(bom_item__consumable=True)
lines = self.build_lines.all().exclude(
part.models.BomItem.consumable_filter(prefix='bom_item__')
)
lines = lines.prefetch_related('allocations')
@ -1794,7 +1805,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
def is_fully_allocated(self) -> bool:
"""Return True if this BuildLine is fully allocated."""
if self.bom_item.consumable:
if self.bom_item.is_consumable:
return True
required = max(0, self.quantity - self.consumed)

View File

@ -996,7 +996,7 @@ class BuildAllocationSerializer(serializers.Serializer):
output = item.get('output', None)
# Ignore allocation for consumable BOM items
if build_line.bom_item.consumable:
if build_line.bom_item.is_consumable:
continue
params = {
@ -1389,7 +1389,7 @@ class BuildLineSerializer(
source='bom_item.reference', label=_('Reference'), read_only=True
)
consumable = serializers.BooleanField(
source='bom_item.consumable', label=_('Consumable'), read_only=True
source='bom_item.is_consumable', label=_('Consumable'), read_only=True
)
optional = serializers.BooleanField(
source='bom_item.optional', label=_('Optional'), read_only=True

View File

@ -1887,6 +1887,77 @@ class BuildLineTests(BuildAPITest):
self.assertSetEqual(false_ids, expected_false)
self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines})
def test_filter_consumable_via_part(self):
"""Filter BuildLine objects by 'consumable' status, accounting for the underlying part.
A BuildLine should be treated as 'consumable' if either the BOM line
itself is marked as consumable, or the underlying part is marked as consumable.
"""
assembly = Part.objects.create(
name='Consumable Filter Assembly',
description='Assembly for consumable filter tests',
assembly=True,
)
plain = Part.objects.create(
name='Consumable Filter Plain Component',
description='A regular component',
component=True,
)
consumable_part = Part.objects.create(
name='Consumable Filter Consumable Part',
description='A part marked as consumable',
component=True,
consumable=True,
)
consumable_line_part = Part.objects.create(
name='Consumable Filter Consumable BOM Line',
description='A part which is consumable only via its BOM line',
component=True,
)
bom_item_plain = BomItem.objects.create(
part=assembly, sub_part=plain, quantity=1
)
bom_item_part_consumable = BomItem.objects.create(
part=assembly, sub_part=consumable_part, quantity=1
)
bom_item_line_consumable = BomItem.objects.create(
part=assembly, sub_part=consumable_line_part, quantity=1, consumable=True
)
build = Build.objects.create(
part=assembly,
reference='BO-9997',
quantity=1,
title='Consumable Filter Build',
)
url = reverse('api-build-line-list')
response = self.get(url, data={'build': build.pk, 'consumable': True})
returned_bom_items = {item['bom_item'] for item in response.data}
self.assertIn(bom_item_part_consumable.pk, returned_bom_items)
self.assertIn(bom_item_line_consumable.pk, returned_bom_items)
self.assertNotIn(bom_item_plain.pk, returned_bom_items)
response = self.get(url, data={'build': build.pk, 'consumable': False})
returned_bom_items = {item['bom_item'] for item in response.data}
self.assertIn(bom_item_plain.pk, returned_bom_items)
self.assertNotIn(bom_item_part_consumable.pk, returned_bom_items)
self.assertNotIn(bom_item_line_consumable.pk, returned_bom_items)
# Check that the serialized 'consumable' field reflects the combined status
response = self.get(
url, data={'build': build.pk, 'bom_item': bom_item_part_consumable.pk}
)
self.assertEqual(len(response.data), 1)
self.assertTrue(response.data[0]['consumable'])
def test_output_options(self):
"""Test output options for the BuildLine endpoint."""
self.run_output_test(

View File

@ -998,6 +998,38 @@ class AutoAllocationTests(BuildTestBase):
self.assertEqual(self.build.allocated_stock.count(), N - 8)
def test_consumable_via_part(self):
"""A BOM line should be treated as consumable if the underlying part is consumable.
Even though 'bom_item_1' itself is not marked as consumable, marking
'sub_part_1' as consumable should have the same effect as marking the
BOM line itself as consumable.
"""
self.sub_part_1.consumable = True
self.sub_part_1.save()
self.assertFalse(self.bom_item_1.consumable)
self.assertTrue(self.bom_item_1.is_consumable)
# The BuildLine should be treated as fully allocated, without any stock allocated
self.assertEqual(self.line_1.allocated_quantity(), 0)
self.assertTrue(self.line_1.is_fully_allocated())
# The build should not consider this line when checking for unallocated lines
unallocated_lines = self.build.unallocated_lines(tracked=False)
self.assertNotIn(self.line_1, unallocated_lines)
# Auto-allocation should skip this line, even though stock exists
self.build.auto_allocate_stock(
interchangeable=True, substitutes=True, optional_items=True
)
self.assertEqual(self.line_1.allocated_quantity(), 0)
self.assertEqual(BuildItem.objects.filter(build_line=self.line_1).count(), 0)
# The other (non-consumable) line should still be allocated as normal
self.assertEqual(self.line_2.allocated_quantity(), 30)
class ExternalBuildTest(InvenTreeAPITestCase):
"""Unit tests for external build order functionality."""

View File

@ -925,6 +925,8 @@ class PartFilter(FilterSet):
virtual = rest_filters.BooleanFilter()
consumable = rest_filters.BooleanFilter()
tags = common.filters.TagsFilter()
# Created date filters

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.15 on 2026-07-02 07:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("part", "0150_part_maximum_stock"),
]
operations = [
migrations.AddField(
model_name="part",
name="consumable",
field=models.BooleanField(
default=False,
help_text="Is this part consumable, such as glue or a fastener?",
verbose_name="Consumable",
),
),
]

View File

@ -503,6 +503,7 @@ class Part(
active: Is this part active? Parts are deactivated instead of being deleted
locked: This part is locked and cannot be edited
virtual: Is this part "virtual"? e.g. a software product or similar
consumable: Is this part consumable, such as glue or a fastener?
notes: Additional notes field for this part
creation_date: Date that this part was added to the database
creation_user: User who added this part to the database
@ -1308,6 +1309,12 @@ class Part(
help_text=_('Is this a virtual part, such as a software product or license?'),
)
consumable = models.BooleanField(
default=False,
verbose_name=_('Consumable'),
help_text=_('Is this part consumable, such as glue or a fastener?'),
)
bom_validated = models.BooleanField(
default=False,
verbose_name=_('BOM Validated'),
@ -1619,7 +1626,7 @@ class Part(
queryset = self.get_bom_items(include_virtual=False)
# Ignore 'consumable' BOM items for this calculation
queryset = queryset.filter(consumable=False)
queryset = queryset.filter(BomItem.consumable_filter(consumable=False))
# Annotate the queryset with the 'can_build' quantity
queryset = part.filters.annotate_bom_item_can_build(queryset)
@ -4288,6 +4295,35 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
return self.get_item_hash() == self.checksum
@property
def is_consumable(self) -> bool:
"""Return True if this BOM line should be treated as consumable.
This is the case if either:
- The BOM line itself is marked as consumable
- The underlying part is marked as consumable
"""
return self.consumable or self.sub_part.consumable
@staticmethod
def consumable_filter(consumable: bool = True, prefix: str = '') -> Q:
"""Return a Q filter which selects BomItem objects based on "effective" consumable status.
A BomItem is considered "effectively consumable" if either the BOM line itself,
or the underlying part, is marked as consumable.
Arguments:
consumable: If True, return a filter which matches consumable BOM items.
If False, return a filter which matches non-consumable BOM items.
prefix: Optional field lookup prefix, for use against querysets of a
related model (e.g. 'bom_item__' when filtering a BuildLine queryset).
"""
f = Q(**{f'{prefix}consumable': True}) | Q(**{
f'{prefix}sub_part__consumable': True
})
return f if consumable else ~f
def clean(self):
"""Check validity of the BomItem model.

View File

@ -357,6 +357,7 @@ class PartBriefSerializer(
'testable',
'trackable',
'virtual',
'consumable',
'units',
'pricing_min',
'pricing_max',
@ -588,6 +589,7 @@ class PartSerializer(
'units',
'variant_of',
'virtual',
'consumable',
'pricing_min',
'pricing_max',
'pricing_updated',

View File

@ -277,6 +277,52 @@ class BomItemTest(TestCase):
self.assertEqual(assembly.can_build, 20)
# Mark 'c4' as consumable at the *part* level (not the BOM line itself)
c4.consumable = True
c4.save()
bom_item_c4 = BomItem.objects.create(part=assembly, sub_part=c4, quantity=200)
# The raw BomItem field is unset, but the part is marked as consumable
self.assertFalse(bom_item_c4.consumable)
self.assertTrue(bom_item_c4.is_consumable)
# A BomItem which is consumable via its part does not alter the can_build calculation
self.assertEqual(assembly.can_build, 20)
def test_consumable_filter(self):
"""Tests for the BomItem.consumable_filter() helper method."""
assembly = Part.objects.create(
name='Another assembly', description='Made with parts', assembly=True
)
c1 = Part.objects.create(name='D1', description='Not consumable')
c2 = Part.objects.create(name='D2', description='Consumable BOM line')
c3 = Part.objects.create(
name='D3', description='Consumable part', consumable=True
)
bom_item_1 = BomItem.objects.create(part=assembly, sub_part=c1, quantity=1)
bom_item_2 = BomItem.objects.create(
part=assembly, sub_part=c2, quantity=1, consumable=True
)
bom_item_3 = BomItem.objects.create(part=assembly, sub_part=c3, quantity=1)
consumable_items = set(
BomItem.objects.filter(BomItem.consumable_filter(consumable=True))
)
non_consumable_items = set(
BomItem.objects.filter(BomItem.consumable_filter(consumable=False))
)
self.assertIn(bom_item_2, consumable_items)
self.assertIn(bom_item_3, consumable_items)
self.assertNotIn(bom_item_1, consumable_items)
self.assertIn(bom_item_1, non_consumable_items)
self.assertNotIn(bom_item_2, non_consumable_items)
self.assertNotIn(bom_item_3, non_consumable_items)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [BomItem]:

View File

@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
import structlog
import part.models
from build.events import BuildEvents
from build.models import Build
from build.status_codes import BuildStatus
@ -56,7 +57,8 @@ class AutoCreateBuildsPlugin(EventMixin, InvenTreePlugin):
"""Process a build order when it is issued."""
# Iterate through all sub-assemblies for the build order
for bom_item in build.part.get_bom_items().filter(
consumable=False, sub_part__assembly=True
part.models.BomItem.consumable_filter(consumable=False),
sub_part__assembly=True,
):
subassembly = bom_item.sub_part

View File

@ -103,6 +103,9 @@ export function usePartFields({
setVirtual(value);
}
},
consumable: {
default: false
},
locked: {},
active: {},
starred: {

View File

@ -83,6 +83,7 @@ import {
type IconProps,
IconQrcode,
IconQuestionMark,
IconRecycle,
IconRefresh,
IconRulerMeasure,
IconSearch,
@ -207,6 +208,7 @@ const icons: InvenTreeIconType = {
purchaseable: IconShoppingCart,
saleable: IconCurrencyDollar,
virtual: IconWorldCode,
consumable: IconRecycle,
inactive: IconX,
part: IconBox,
supplier_part: IconPackageImport,

View File

@ -549,6 +549,11 @@ export default function PartDetail() {
name: 'virtual',
label: t`Virtual Part`
},
{
type: 'boolean',
name: 'consumable',
label: t`Consumable Part`
},
{
type: 'boolean',
name: 'starred',
@ -1002,10 +1007,16 @@ export default function PartDetail() {
key='inactive'
/>,
<DetailsBadge
label={t`Virtual Part`}
label={t`Virtual`}
color='cyan.4'
visible={part.virtual}
key='virtual'
/>,
<DetailsBadge
label={t`Consumable`}
color='cyan.4'
visible={part.consumable}
key='consumable'
/>
];
}, [partRequirements, partRequirementsQuery.isFetching, part]);

View File

@ -1,6 +1,7 @@
import { ActionButton } from '@lib/components/ActionButton';
import { ProgressBar } from '@lib/components/ProgressBar';
import { RowEditAction, RowViewAction } from '@lib/components/RowActions';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@ -10,7 +11,7 @@ import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { Alert, Group, Paper, Text } from '@mantine/core';
import { Alert, Center, Group, Paper, Text } from '@mantine/core';
import {
IconArrowRight,
IconCircleCheck,
@ -54,6 +55,16 @@ import { InvenTreeTable } from '../InvenTreeTable';
import RowExpansionIcon from '../RowExpansionIcon';
import { TableHoverCard } from '../TableHoverCard';
/**
* Return true if the given build line record is "effectively consumable" -
* i.e. either the BOM line itself, or the underlying part, is marked as consumable.
*/
function isLineConsumable(record: any): boolean {
return (
!!record?.bom_item_detail?.consumable || !!record?.part_detail?.consumable
);
}
/**
* Render a sub-table of allocated stock against a particular build line.
*
@ -366,7 +377,12 @@ export default function BuildLineTable({
ordering: 'consumable',
filter: 'consumable',
hidden: hasOutput,
defaultVisible: false
defaultVisible: false,
render: (record: any) => (
<Center>
<YesNoButton value={isLineConsumable(record)} />
</Center>
)
}),
BooleanColumn({
accessor: 'bom_item_detail.allow_variants',
@ -499,7 +515,7 @@ export default function BuildLineTable({
hidden: !isActive,
minWidth: 125,
render: (record: any) => {
if (record?.bom_item_detail?.consumable) {
if (isLineConsumable(record)) {
return (
<Text
size='sm'
@ -545,7 +561,7 @@ export default function BuildLineTable({
hidden: !!output?.pk,
minWidth: 125,
render: (record: any) => {
return record?.bom_item_detail?.consumable ? (
return isLineConsumable(record) ? (
<Text
size='sm'
style={{ fontStyle: 'italic' }}
@ -747,7 +763,7 @@ export default function BuildLineTable({
(record: any): RowAction[] => {
const part = record.part_detail ?? {};
const in_production = build.status == buildStatus.PRODUCTION;
const consumable: boolean = record.bom_item_detail?.consumable ?? false;
const consumable: boolean = isLineConsumable(record);
const trackable: boolean = part?.trackable ?? false;
const hasOutput: boolean = !!output?.pk;
@ -907,7 +923,7 @@ export default function BuildLineTable({
onClick={() => {
let rows = table.selectedRecords
.filter((r) => r.allocatedQuantity < r.requiredQuantity)
.filter((r) => !r.bom_item_detail?.consumable);
.filter((r) => !isLineConsumable(r));
if (hasOutput) {
rows = rows.filter((r) => r.trackable);

View File

@ -216,6 +216,10 @@ function partTableColumns(): TableColumn[] {
accessor: 'virtual',
defaultVisible: false
}),
BooleanColumn({
accessor: 'consumable',
defaultVisible: false
}),
LinkColumn({})
];
}

View File

@ -104,6 +104,12 @@ export function PartTableFilters(): TableFilter[] {
description: t`Filter by parts which are virtual`,
type: 'boolean'
},
{
name: 'consumable',
label: t`Consumable`,
description: t`Filter by parts which are consumable`,
type: 'boolean'
},
{
name: 'is_template',
label: t`Is Template`,