Merge d5045ea8c6 into f656ac7263
This commit is contained in:
commit
98e544f725
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue