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