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/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 new file mode 100644 index 0000000000..b9a60beae2 --- /dev/null +++ b/docs/docs/part/consumable.md @@ -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. 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 f24d8db82c..3e43998792 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 diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 26ce0c47d7..d0fdaf62c0 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 = 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 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' ) 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 3864061b7d..d542671e00 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -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 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/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/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..33d1d683ab 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'), @@ -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. diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index b2b7a361cb..e8b2ecff29 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', @@ -588,6 +589,7 @@ class PartSerializer( 'units', 'variant_of', 'virtual', + 'consumable', 'pricing_min', 'pricing_max', 'pricing_updated', 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]: 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 diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 0ead66d6d9..e0bfa28e0e 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/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index bdc1826e89..13e9f9ed79 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -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, diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 5349cd3f1c..b159930beb 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', @@ -1002,10 +1007,16 @@ export default function PartDetail() { key='inactive' />, , + ]; }, [partRequirements, partRequirementsQuery.isFetching, part]); 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); diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx index bd207b1f1a..fd3f124607 100644 --- a/src/frontend/src/tables/part/PartTable.tsx +++ b/src/frontend/src/tables/part/PartTable.tsx @@ -216,6 +216,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`,