diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 72fd4938a6..2f240cd9ac 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -18,7 +18,7 @@ body: attributes: label: "Problem statement" description: "A clear and concise description of what the solved problem or feature request is." - placeholder: "I am always struggeling with ..." + placeholder: "I am always struggling with ..." - type: textarea id: solution validations: diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 5852fe4965..19bf8b85aa 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -839,6 +839,10 @@ class Build(MPTTModel, ReferenceIndexingMixin): # Get a list of all 'untracked' BOM items for bom_item in self.untracked_bom_items: + if bom_item.consumable: + # Do not auto-allocate stock to consumable BOM items + continue + variant_parts = bom_item.sub_part.get_descendants(include_self=False) unallocated_quantity = self.unallocated_quantity(bom_item) @@ -972,7 +976,12 @@ class Build(MPTTModel, ReferenceIndexingMixin): return max(required - allocated, 0) def is_bom_item_allocated(self, bom_item, output=None): - """Test if the supplied BomItem has been fully allocated!""" + """Test if the supplied BomItem has been fully allocated""" + + if bom_item.consumable: + # Consumable BOM items do not need to be allocated + return True + return self.unallocated_quantity(bom_item, output) == 0 def is_fully_allocated(self, output): diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index d961e024a8..fd880c7a38 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -781,6 +781,10 @@ class BuildAllocationSerializer(serializers.Serializer): quantity = item['quantity'] output = item.get('output', None) + # Ignore allocation for consumable BOM items + if bom_item.consumable: + continue + try: # Create a new BuildItem to allocate stock BuildItem.objects.create( diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a0177380f8..59fba9b2c8 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1640,8 +1640,9 @@ class BomFilter(rest_filters.FilterSet): """Custom filters for the BOM list.""" # Boolean filters for BOM item - optional = rest_filters.BooleanFilter(label='BOM line is optional') - inherited = rest_filters.BooleanFilter(label='BOM line is inherited') + optional = rest_filters.BooleanFilter(label='BOM item is optional') + consumable = rest_filters.BooleanFilter(label='BOM item is consumable') + inherited = rest_filters.BooleanFilter(label='BOM item is inherited') allow_variants = rest_filters.BooleanFilter(label='Variants are allowed') # Filters for linked 'part' diff --git a/InvenTree/part/migrations/0087_bomitem_consumable.py b/InvenTree/part/migrations/0087_bomitem_consumable.py new file mode 100644 index 0000000000..79c793c99a --- /dev/null +++ b/InvenTree/part/migrations/0087_bomitem_consumable.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-04-28 00:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0086_auto_20220912_0007'), + ] + + operations = [ + migrations.AddField( + model_name='bomitem', + name='consumable', + field=models.BooleanField(default=False, help_text='This BOM item is consumable (it is not tracked in build orders)', verbose_name='Consumable'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 6fe06e49db..5ab60edbd5 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1134,7 +1134,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): total = None # Prefetch related tables, to reduce query expense - queryset = self.get_bom_items().prefetch_related( + queryset = self.get_bom_items() + + # Ignore 'consumable' BOM items for this calculation + queryset = queryset.filter(consumable=False) + + queryset = queryset.prefetch_related( 'sub_part__stock_items', 'sub_part__stock_items__allocations', 'sub_part__stock_items__sales_order_allocations', @@ -2526,6 +2531,7 @@ class BomItem(DataImportMixin, models.Model): sub_part: Link to the child part (the part that will be consumed) quantity: Number of 'sub_parts' consumed to produce one 'part' optional: Boolean field describing if this BomItem is optional + consumable: Boolean field describing if this BomItem is considered a 'consumable' reference: BOM reference field (e.g. part designators) overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%') note: Note field for this BOM item @@ -2544,6 +2550,7 @@ class BomItem(DataImportMixin, models.Model): 'allow_variants': {}, 'inherited': {}, 'optional': {}, + 'consumable': {}, 'note': {}, 'part': { 'label': _('Part'), @@ -2649,7 +2656,17 @@ class BomItem(DataImportMixin, models.Model): # Quantity required quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('BOM quantity for this BOM item')) - optional = models.BooleanField(default=False, verbose_name=_('Optional'), help_text=_("This BOM item is optional")) + optional = models.BooleanField( + default=False, + verbose_name=_('Optional'), + help_text=_("This BOM item is optional") + ) + + consumable = models.BooleanField( + default=False, + verbose_name=_('Consumable'), + help_text=_("This BOM item is consumable (it is not tracked in build orders)") + ) overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage], verbose_name=_('Overage'), @@ -2698,6 +2715,14 @@ class BomItem(DataImportMixin, models.Model): result_hash.update(str(self.optional).encode()) result_hash.update(str(self.inherited).encode()) + # Optionally encoded for backwards compatibility + if self.consumable: + result_hash.update(str(self.consumable).encode()) + + # Optionally encoded for backwards compatibility + if self.allow_variants: + result_hash.update(str(self.allow_variants).encode()) + return str(result_hash.digest()) def validate_hash(self, valid=True): diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 14140b69d7..5a8aafb0b2 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -760,6 +760,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 'inherited', 'note', 'optional', + 'consumable', 'overage', 'pk', 'part', diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index d7e92d1da4..ed93c0f54d 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -118,6 +118,7 @@ class BomExportTest(InvenTreeTestCase): 'sub_assembly', 'quantity', 'optional', + 'consumable', 'overage', 'reference', 'note', diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 58c5dfb032..266ae94aec 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -7,6 +7,8 @@ import django.core.exceptions as django_exceptions from django.db import transaction from django.test import TestCase +import stock.models + from .models import BomItem, BomItemSubstitute, Part @@ -197,3 +199,49 @@ class BomItemTest(TestCase): # The substitution links should have been automatically removed self.assertEqual(bom_item.substitutes.count(), 0) + + def test_consumable(self): + """Tests for the 'consumable' BomItem field""" + + # Create an assembly part + assembly = Part.objects.create(name="An assembly", description="Made with parts", assembly=True) + + # No BOM information initially + self.assertEqual(assembly.can_build, 0) + + # Create some component items + c1 = Part.objects.create(name="C1", description="C1") + c2 = Part.objects.create(name="C2", description="C2") + c3 = Part.objects.create(name="C3", description="C3") + c4 = Part.objects.create(name="C4", description="C4") + + for p in [c1, c2, c3, c4]: + # Ensure we have stock + stock.models.StockItem.objects.create(part=p, quantity=1000) + + # Create some BOM items + BomItem.objects.create( + part=assembly, + sub_part=c1, + quantity=10 + ) + + self.assertEqual(assembly.can_build, 100) + + BomItem.objects.create( + part=assembly, + sub_part=c2, + quantity=50, + consumable=True + ) + + # A 'consumable' BomItem does not alter the can_build calculation + self.assertEqual(assembly.can_build, 100) + + BomItem.objects.create( + part=assembly, + sub_part=c3, + quantity=50, + ) + + self.assertEqual(assembly.can_build, 20) diff --git a/InvenTree/stock/migrations/0088_remove_stockitem_infinite.py b/InvenTree/stock/migrations/0088_remove_stockitem_infinite.py new file mode 100644 index 0000000000..65df560bf6 --- /dev/null +++ b/InvenTree/stock/migrations/0088_remove_stockitem_infinite.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.15 on 2022-09-22 02:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0087_auto_20220912_2341'), + ] + + operations = [ + migrations.RemoveField( + model_name='stockitem', + name='infinite', + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 3724653eab..60d994def1 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -224,7 +224,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): build: Link to a Build (if this stock item was created from a build) is_building: Boolean field indicating if this stock item is currently being built (or is "in production") purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder) - infinite: If True this StockItem can never be exhausted sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder) purchase_price: The unit purchase price for this StockItem - this is the unit price at time of purchase (if this item was purchased from an external supplier) packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc) @@ -882,11 +881,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): self.save() - # If stock item is incoming, an (optional) ETA field - # expected_arrival = models.DateField(null=True, blank=True) - - infinite = models.BooleanField(default=False) - def is_allocated(self): """Return True if this StockItem is allocated to a SalesOrder or a Build.""" # TODO - For now this only checks if the StockItem is allocated to a SalesOrder @@ -1565,7 +1559,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): except InvalidOperation: return False - if count < 0 or self.infinite: + if count < 0: return False self.stocktake_date = datetime.now().date() @@ -1601,7 +1595,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): return False # Ignore amounts that do not make sense - if quantity <= 0 or self.infinite: + if quantity <= 0: return False if self.updateQuantity(self.quantity + quantity): @@ -1630,7 +1624,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): except InvalidOperation: return False - if quantity <= 0 or self.infinite: + if quantity <= 0: return False if self.updateQuantity(self.quantity - quantity): diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index e41b96cd09..8a49454027 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -382,6 +382,7 @@ function bomItemFields() { note: {}, allow_variants: {}, inherited: {}, + consumable: {}, optional: {}, }; @@ -761,7 +762,22 @@ function loadBomTable(table, options={}) { } return available; + } + function canBuildQuantity(row) { + // Calculate how many of each row we can make, given current stock + + if (row.consumable) { + // If the row is "consumable" we do not 'track' the quantity + return Infinity; + } + + // Prevent div-by-zero or negative errors + if ((row.quantity || 0) <= 0) { + return 0; + } + + return availableQuantity(row) / row.quantity; } // Construct the table columns @@ -844,6 +860,9 @@ function loadBomTable(table, options={}) { { field: 'sub_part_detail.description', title: '{% trans "Description" %}', + formatter: function(value) { + return withTitle(shortenString(value), value); + } } ); @@ -872,8 +891,12 @@ function loadBomTable(table, options={}) { text += ` ${row.sub_part_detail.units}`; } + if (row.consumable) { + text += ` ({% trans "Consumable" %})`; + } + if (row.optional) { - text += ' ({% trans "Optional" %})'; + text += ' ({% trans "Optional" %})'; } if (row.overage) { @@ -966,40 +989,11 @@ function loadBomTable(table, options={}) { if (row.substitutes && row.substitutes.length > 0) { return row.substitutes.length; } else { - return `-`; + return yesNoLabel(false); } } }); - if (show_pricing) { - cols.push({ - field: 'purchase_price_range', - title: '{% trans "Purchase Price Range" %}', - searchable: false, - sortable: true, - }); - - cols.push({ - field: 'purchase_price_avg', - title: '{% trans "Purchase Price Average" %}', - searchable: false, - sortable: true, - }); - - cols.push({ - field: 'price_range', - title: '{% trans "Supplier Cost" %}', - sortable: true, - formatter: function(value) { - if (value) { - return value; - } else { - return `{% trans 'No supplier pricing available' %}`; - } - } - }); - } - cols.push({ field: 'optional', title: '{% trans "Optional" %}', @@ -1009,6 +1003,15 @@ function loadBomTable(table, options={}) { } }); + cols.push({ + field: 'consumable', + title: '{% trans "Consumable" %}', + searchable: false, + formatter: function(value) { + return yesNoLabel(value); + } + }); + cols.push({ field: 'allow_variants', title: '{% trans "Allow Variants" %}', @@ -1037,36 +1040,63 @@ function loadBomTable(table, options={}) { } }); + if (show_pricing) { + cols.push({ + field: 'purchase_price_range', + title: '{% trans "Purchase Price Range" %}', + searchable: false, + sortable: true, + }); + + cols.push({ + field: 'purchase_price_avg', + title: '{% trans "Purchase Price Average" %}', + searchable: false, + sortable: true, + }); + + cols.push({ + field: 'price_range', + title: '{% trans "Supplier Cost" %}', + sortable: true, + formatter: function(value) { + if (value) { + return value; + } else { + return `{% trans 'No supplier pricing available' %}`; + } + } + }); + } + cols.push( { field: 'can_build', title: '{% trans "Can Build" %}', + sortable: true, formatter: function(value, row) { - var can_build = 0; - var available = availableQuantity(row); - - if (row.quantity > 0) { - can_build = available / row.quantity; + // "Consumable" parts are not tracked in the build + if (row.consumable) { + return `{% trans "Consumable item" %}`; } - var text = formatDecimal(can_build, 2); + var can_build = canBuildQuantity(row); - // Take "on order" quantity into account - if (row.on_order && row.on_order > 0 && row.quantity > 0) { - available += row.on_order; - can_build = available / row.quantity; + return +can_build.toFixed(2); + }, + sorter: function(valA, valB, rowA, rowB) { + // Function to sort the "can build" quantity + var cb_a = canBuildQuantity(rowA); + var cb_b = canBuildQuantity(rowB); - text += ``; - } - - return text; + return (cb_a > cb_b) ? 1 : -1; }, footerFormatter: function(data) { var can_build = null; data.forEach(function(row) { - if (row.part == options.parent_id && row.quantity > 0) { + if (row.quantity > 0 && !row.consumable) { var cb = availableQuantity(row) / row.quantity; if (can_build == null || cb < can_build) { @@ -1080,23 +1110,7 @@ function loadBomTable(table, options={}) { } else { return formatDecimal(can_build, 2); } - }, - sorter: function(valA, valB, rowA, rowB) { - // Function to sort the "can build" quantity - var cb_a = 0; - var cb_b = 0; - - if (rowA.quantity > 0) { - cb_a = availableQuantity(rowA) / rowA.quantity; - } - - if (rowB.quantity > 0) { - cb_b = availableQuantity(rowB) / rowB.quantity; - } - - return (cb_a > cb_b) ? 1 : -1; - }, - sortable: true, + } } ); @@ -1107,6 +1121,9 @@ function loadBomTable(table, options={}) { title: '{% trans "Notes" %}', searchable: true, sortable: true, + formatter: function(value) { + return withTitle(shortenString(value), value); + } } ); diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index b09be32ffd..1e3eb85aa2 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1859,33 +1859,37 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var icons = ''; - if (available_stock < (required - allocated)) { - icons += ``; + if (row.consumable) { + icons += ``; } else { - icons += ``; + if (available_stock < (required - allocated)) { + icons += ``; + } else { + icons += ``; + } + + if (available_stock <= 0) { + icons += `{% trans "No Stock Available" %}`; + } else { + var extra = ''; + if ((substitute_stock > 0) && (variant_stock > 0)) { + extra = '{% trans "Includes variant and substitute stock" %}'; + } else if (variant_stock > 0) { + extra = '{% trans "Includes variant stock" %}'; + } else if (substitute_stock > 0) { + extra = '{% trans "Includes substitute stock" %}'; + } + + if (extra) { + icons += ``; + } + } } if (row.on_order && row.on_order > 0) { icons += ``; } - if (available_stock <= 0) { - icons += `{% trans "No Stock Available" %}`; - } else { - var extra = ''; - if ((substitute_stock > 0) && (variant_stock > 0)) { - extra = '{% trans "Includes variant and substitute stock" %}'; - } else if (variant_stock > 0) { - extra = '{% trans "Includes variant stock" %}'; - } else if (substitute_stock > 0) { - extra = '{% trans "Includes substitute stock" %}'; - } - - if (extra) { - icons += ``; - } - } - return renderLink(text, url) + icons; }, sorter: function(valA, valB, rowA, rowB) { @@ -1898,8 +1902,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { title: '{% trans "Allocated" %}', sortable: true, formatter: function(value, row) { - var allocated = allocatedQuantity(row); var required = requiredQuantity(row); + var allocated = row.consumable ? required : allocatedQuantity(row); return makeProgressBar(allocated, required); }, sorter: function(valA, valB, rowA, rowB) { @@ -1938,6 +1942,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { field: 'actions', title: '{% trans "Actions" %}', formatter: function(value, row) { + + if (row.consumable) { + return `{% trans "Consumable item" %}`; + } + // Generate action buttons for this build output var html = `
`; @@ -2093,6 +2102,11 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { for (var idx = 0; idx < bom_items.length; idx++) { var bom_item = bom_items[idx]; + // Ignore "consumable" BOM items + if (bom_item.consumable) { + continue; + } + var required = bom_item.required || 0; var allocated = bom_item.allocated || 0; var remaining = required - allocated; diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index e817c17af8..62a5935e69 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -79,6 +79,14 @@ function getAvailableTableFilters(tableKey) { type: 'bool', title: '{% trans "Allow Variant Stock" %}', }, + optional: { + type: 'bool', + title: '{% trans "Optional" %}', + }, + consumable: { + type: 'bool', + title: '{% trans "Consumable" %}', + }, }; }