From da4cc739da2b4ee9117cf4dfd3fadc85086cf00e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 2 Jul 2026 07:26:04 +0000 Subject: [PATCH 01/13] Add "consumable" field to the Part model --- .../part/migrations/0151_part_consumable.py | 22 +++++++++++++++++++ src/backend/InvenTree/part/models.py | 7 ++++++ src/backend/InvenTree/part/serializers.py | 2 ++ 3 files changed, 31 insertions(+) create mode 100644 src/backend/InvenTree/part/migrations/0151_part_consumable.py diff --git a/src/backend/InvenTree/part/migrations/0151_part_consumable.py b/src/backend/InvenTree/part/migrations/0151_part_consumable.py new file mode 100644 index 0000000000..673313e25e --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0151_part_consumable.py @@ -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", + ), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 876b5e521b..9cc2c4295a 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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'), diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index a9a87c7e80..051abd27a1 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -357,6 +357,7 @@ class PartBriefSerializer( 'testable', 'trackable', 'virtual', + 'consumable', 'units', 'pricing_min', 'pricing_max', @@ -649,6 +650,7 @@ class PartSerializer( 'units', 'variant_of', 'virtual', + 'consumable', 'pricing_min', 'pricing_max', 'pricing_updated', From 7f194d09dd01637e13e8986cc63cdc4a96f1c5a0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 2 Jul 2026 07:26:41 +0000 Subject: [PATCH 02/13] Add frontend integration --- src/frontend/src/forms/PartForms.tsx | 3 +++ src/frontend/src/pages/part/PartDetail.tsx | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 311b64b18f..38219a924c 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -103,6 +103,9 @@ export function usePartFields({ setVirtual(value); } }, + consumable: { + default: false + }, locked: {}, active: {}, starred: { diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 5349cd3f1c..7173fa7b70 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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', From 36a208eb529c29ba896052c8509dd2d9c0491085 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 2 Jul 2026 07:34:15 +0000 Subject: [PATCH 03/13] Add badge --- src/frontend/src/pages/part/PartDetail.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 7173fa7b70..b159930beb 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -1007,10 +1007,16 @@ export default function PartDetail() { key='inactive' />, , + ]; }, [partRequirements, partRequirementsQuery.isFetching, part]); From 1bdd7fbce4b350e80d5a3ad7cb5d25a9a7f80750 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 2 Jul 2026 07:44:51 +0000 Subject: [PATCH 04/13] Add docs --- docs/docs/part/consumable.md | 31 +++++++++++++++++++++++++++++++ docs/docs/part/index.md | 3 +++ docs/mkdocs.yml | 1 + 3 files changed, 35 insertions(+) create mode 100644 docs/docs/part/consumable.md diff --git a/docs/docs/part/consumable.md b/docs/docs/part/consumable.md new file mode 100644 index 0000000000..c2930f3cf4 --- /dev/null +++ b/docs/docs/part/consumable.md @@ -0,0 +1,31 @@ +--- +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. + +### 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. diff --git a/docs/docs/part/index.md b/docs/docs/part/index.md index 1c7d048268..c6fcd2ab89 100644 --- a/docs/docs/part/index.md +++ b/docs/docs/part/index.md @@ -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 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index ca20e704c7..a5fefc448d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 From 2d65841ad14e937608efe192009169f45c7dec2f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 2 Jul 2026 07:50:31 +0000 Subject: [PATCH 05/13] Updated docs --- docs/docs/manufacturing/bom.md | 2 ++ docs/docs/part/consumable.md | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/docs/manufacturing/bom.md b/docs/docs/manufacturing/bom.md index 7e82cb68a5..76801e65ab 100644 --- a/docs/docs/manufacturing/bom.md +++ b/docs/docs/manufacturing/bom.md @@ -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. diff --git a/docs/docs/part/consumable.md b/docs/docs/part/consumable.md index c2930f3cf4..b9a60beae2 100644 --- a/docs/docs/part/consumable.md +++ b/docs/docs/part/consumable.md @@ -16,6 +16,17 @@ Consumable parts can be used to represent things such as: 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. @@ -29,3 +40,16 @@ Consumable parts can be added as a subcomponent to the [Bills of Material](../ma 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. From 8d96670bbc438174986f3a46614ec6cb0f78a9d8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 2 Jul 2026 07:57:05 +0000 Subject: [PATCH 06/13] Add API filtering for new field --- src/backend/InvenTree/part/api.py | 2 ++ src/frontend/src/tables/part/PartTable.tsx | 4 ++++ src/frontend/src/tables/part/PartTableFilters.tsx | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 1eb603dd7c..9ceb18a424 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -925,6 +925,8 @@ class PartFilter(FilterSet): virtual = rest_filters.BooleanFilter() + consumable = rest_filters.BooleanFilter() + tags = common.filters.TagsFilter() # Created date filters diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx index 715529ddf0..c0d2df7ef0 100644 --- a/src/frontend/src/tables/part/PartTable.tsx +++ b/src/frontend/src/tables/part/PartTable.tsx @@ -215,6 +215,10 @@ function partTableColumns(): TableColumn[] { accessor: 'virtual', defaultVisible: false }), + BooleanColumn({ + accessor: 'consumable', + defaultVisible: false + }), LinkColumn({}) ]; } diff --git a/src/frontend/src/tables/part/PartTableFilters.tsx b/src/frontend/src/tables/part/PartTableFilters.tsx index 9435e7d904..301f264b2c 100644 --- a/src/frontend/src/tables/part/PartTableFilters.tsx +++ b/src/frontend/src/tables/part/PartTableFilters.tsx @@ -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`, From 4b21f9faa6a5ff82f482f6d74607c5f0d3d81abb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 2 Jul 2026 22:53:19 +0000 Subject: [PATCH 07/13] Adjust API filters --- src/backend/InvenTree/build/api.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index a0edd054a1..a50d062720 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -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' ) From 99494ba9dcb42c231c448314bc8f726567217f19 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 2 Jul 2026 22:54:57 +0000 Subject: [PATCH 08/13] Update backend logic --- src/backend/InvenTree/build/models.py | 27 +++++++++++----- src/backend/InvenTree/build/serializers.py | 4 +-- src/backend/InvenTree/part/models.py | 31 ++++++++++++++++++- .../builtin/events/auto_create_builds.py | 4 ++- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 9ee55b8e2a..1547d35c50 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index a4bf2430fd..603da825be 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -975,7 +975,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 = { @@ -1368,7 +1368,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 diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 9cc2c4295a..33d1d683ab 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -1626,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) @@ -4295,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. diff --git a/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py b/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py index f1667fcb98..4cd59f916a 100644 --- a/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py +++ b/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py @@ -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 From 98dc285ebffd8a88d3f21a102fd75ad4776dbb6d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 2 Jul 2026 23:18:29 +0000 Subject: [PATCH 09/13] Adjust frontend --- .../src/tables/build/BuildLineTable.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 7bdfb844c5..27e68a38ed 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -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) => ( +
+ +
+ ) }), 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 ( { - return record?.bom_item_detail?.consumable ? ( + return isLineConsumable(record) ? ( { 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); From 268b638a04646c6ee661143f8c22ce2de618a800 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 3 Jul 2026 02:10:30 +0000 Subject: [PATCH 10/13] Additional unit tests --- src/backend/InvenTree/build/test_api.py | 71 +++++++++++++++++++++ src/backend/InvenTree/build/test_build.py | 32 ++++++++++ src/backend/InvenTree/part/test_bom_item.py | 46 +++++++++++++ 3 files changed, 149 insertions(+) diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 4e815bcfed..a4a35c1a5e 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -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( diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index eaaa5867c4..f0ce20a72c 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -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.""" diff --git a/src/backend/InvenTree/part/test_bom_item.py b/src/backend/InvenTree/part/test_bom_item.py index 13bebc5e3c..a6dda90ad1 100644 --- a/src/backend/InvenTree/part/test_bom_item.py +++ b/src/backend/InvenTree/part/test_bom_item.py @@ -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]: From 91a648c629d387003bbef179e117c6e5691bc1dd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 3 Jul 2026 03:55:01 +0000 Subject: [PATCH 11/13] Update API version --- CHANGELOG.md | 1 + src/backend/InvenTree/InvenTree/api_version.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb687ad6a..bd7c1a6be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index b34c1ef5c5..c0378b7396 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 513 +INVENTREE_API_VERSION = 514 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v514 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12295 + - Adds "consumable" field to the Part model + v513 -> 2026-06-25 : https://github.com/inventree/InvenTree/pull/12250 - Adds "active" field to the ProjectCode model and API endpoints From 1817f8e2ccdbc5eb6d12045360604bd24de7ebbb Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 3 Jul 2026 08:00:49 +0200 Subject: [PATCH 12/13] update test --- src/backend/InvenTree/web/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/web/tests.py b/src/backend/InvenTree/web/tests.py index 04a25b73a1..41190526dc 100644 --- a/src/backend/InvenTree/web/tests.py +++ b/src/backend/InvenTree/web/tests.py @@ -30,7 +30,7 @@ class TemplateTagTest(InvenTreeTestCase): shipped_js = resp.split('