mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-06 23:21:59 +00:00
[feature] Consumable part (#12295)
* Add "consumable" field to the Part model * Add frontend integration * Add badge * Add docs * Updated docs * Add API filtering for new field * Adjust API filters * Update backend logic * Adjust frontend * Additional unit tests * Update API version * update test * Add consumable icon --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 515
|
||||
INVENTREE_API_VERSION = 516
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v516 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12295
|
||||
- Adds "consumable" field to the Part model and API endpoints
|
||||
|
||||
v515 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12298
|
||||
- Change the file fields definition to binary (from uri) in the upload requests
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -925,6 +925,8 @@ class PartFilter(FilterSet):
|
||||
|
||||
virtual = rest_filters.BooleanFilter()
|
||||
|
||||
consumable = rest_filters.BooleanFilter()
|
||||
|
||||
tags = common.filters.TagsFilter()
|
||||
|
||||
# Created date filters
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -103,6 +103,9 @@ export function usePartFields({
|
||||
setVirtual(value);
|
||||
}
|
||||
},
|
||||
consumable: {
|
||||
default: false
|
||||
},
|
||||
locked: {},
|
||||
active: {},
|
||||
starred: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -216,6 +216,10 @@ function partTableColumns(): TableColumn[] {
|
||||
accessor: 'virtual',
|
||||
defaultVisible: false
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'consumable',
|
||||
defaultVisible: false
|
||||
}),
|
||||
LinkColumn({})
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user