Adds 'consumable' field to BomItem model (#2890)

* Adds 'consumable' field to BomItem model

* Add consumable field to API

* Consumable items always count as "allocated" for a build

* Add new BOM fields to BOM item checksum calculation

* Display 'consumable' status in BOM table

* Fix order of database migrations

* Update unit tests

* Fix for BOM table

* Remove "infinite" field from StockItem model

- Not used anywhere for functionality
- Hidden from the user
- Now replaced by the "consumable" concept in the BuildOrder model

* Update build order allocation table display

* Prevent auto-allocation of stock to consumable BOM items

* Ignore consumable BOM items when allocating stock to a build order

* Reimplmement "footer" row for BOM table

* Fix "can_build" calculation

- Ignore "consumable" BOM items

* Unrelated typo fix

* Tweak BOM table

* More visual tweaks to BOM table

* Add unit test for consumable field
This commit is contained in:
Oliver 2022-09-24 23:45:56 +10:00 committed by GitHub
parent a7e4d27d6d
commit 1b421fb59a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 256 additions and 99 deletions

View File

@ -18,7 +18,7 @@ body:
attributes: attributes:
label: "Problem statement" label: "Problem statement"
description: "A clear and concise description of what the solved problem or feature request is." 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 - type: textarea
id: solution id: solution
validations: validations:

View File

@ -839,6 +839,10 @@ class Build(MPTTModel, ReferenceIndexingMixin):
# Get a list of all 'untracked' BOM items # Get a list of all 'untracked' BOM items
for bom_item in self.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) variant_parts = bom_item.sub_part.get_descendants(include_self=False)
unallocated_quantity = self.unallocated_quantity(bom_item) unallocated_quantity = self.unallocated_quantity(bom_item)
@ -972,7 +976,12 @@ class Build(MPTTModel, ReferenceIndexingMixin):
return max(required - allocated, 0) return max(required - allocated, 0)
def is_bom_item_allocated(self, bom_item, output=None): 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 return self.unallocated_quantity(bom_item, output) == 0
def is_fully_allocated(self, output): def is_fully_allocated(self, output):

View File

@ -781,6 +781,10 @@ class BuildAllocationSerializer(serializers.Serializer):
quantity = item['quantity'] quantity = item['quantity']
output = item.get('output', None) output = item.get('output', None)
# Ignore allocation for consumable BOM items
if bom_item.consumable:
continue
try: try:
# Create a new BuildItem to allocate stock # Create a new BuildItem to allocate stock
BuildItem.objects.create( BuildItem.objects.create(

View File

@ -1640,8 +1640,9 @@ class BomFilter(rest_filters.FilterSet):
"""Custom filters for the BOM list.""" """Custom filters for the BOM list."""
# Boolean filters for BOM item # Boolean filters for BOM item
optional = rest_filters.BooleanFilter(label='BOM line is optional') optional = rest_filters.BooleanFilter(label='BOM item is optional')
inherited = rest_filters.BooleanFilter(label='BOM line is inherited') 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') allow_variants = rest_filters.BooleanFilter(label='Variants are allowed')
# Filters for linked 'part' # Filters for linked 'part'

View File

@ -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'),
),
]

View File

@ -1134,7 +1134,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
total = None total = None
# Prefetch related tables, to reduce query expense # 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',
'sub_part__stock_items__allocations', 'sub_part__stock_items__allocations',
'sub_part__stock_items__sales_order_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) sub_part: Link to the child part (the part that will be consumed)
quantity: Number of 'sub_parts' consumed to produce one 'part' quantity: Number of 'sub_parts' consumed to produce one 'part'
optional: Boolean field describing if this BomItem is optional 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) 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%') 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 note: Note field for this BOM item
@ -2544,6 +2550,7 @@ class BomItem(DataImportMixin, models.Model):
'allow_variants': {}, 'allow_variants': {},
'inherited': {}, 'inherited': {},
'optional': {}, 'optional': {},
'consumable': {},
'note': {}, 'note': {},
'part': { 'part': {
'label': _('Part'), 'label': _('Part'),
@ -2649,7 +2656,17 @@ class BomItem(DataImportMixin, models.Model):
# Quantity required # 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')) 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], overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
verbose_name=_('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.optional).encode())
result_hash.update(str(self.inherited).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()) return str(result_hash.digest())
def validate_hash(self, valid=True): def validate_hash(self, valid=True):

View File

@ -760,6 +760,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
'inherited', 'inherited',
'note', 'note',
'optional', 'optional',
'consumable',
'overage', 'overage',
'pk', 'pk',
'part', 'part',

View File

@ -118,6 +118,7 @@ class BomExportTest(InvenTreeTestCase):
'sub_assembly', 'sub_assembly',
'quantity', 'quantity',
'optional', 'optional',
'consumable',
'overage', 'overage',
'reference', 'reference',
'note', 'note',

View File

@ -7,6 +7,8 @@ import django.core.exceptions as django_exceptions
from django.db import transaction from django.db import transaction
from django.test import TestCase from django.test import TestCase
import stock.models
from .models import BomItem, BomItemSubstitute, Part from .models import BomItem, BomItemSubstitute, Part
@ -197,3 +199,49 @@ class BomItemTest(TestCase):
# The substitution links should have been automatically removed # The substitution links should have been automatically removed
self.assertEqual(bom_item.substitutes.count(), 0) 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)

View File

@ -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',
),
]

View File

@ -224,7 +224,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
build: Link to a Build (if this stock item was created from a build) 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") 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) 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) 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) 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) 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() 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): def is_allocated(self):
"""Return True if this StockItem is allocated to a SalesOrder or a Build.""" """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 # 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: except InvalidOperation:
return False return False
if count < 0 or self.infinite: if count < 0:
return False return False
self.stocktake_date = datetime.now().date() self.stocktake_date = datetime.now().date()
@ -1601,7 +1595,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
return False return False
# Ignore amounts that do not make sense # Ignore amounts that do not make sense
if quantity <= 0 or self.infinite: if quantity <= 0:
return False return False
if self.updateQuantity(self.quantity + quantity): if self.updateQuantity(self.quantity + quantity):
@ -1630,7 +1624,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
except InvalidOperation: except InvalidOperation:
return False return False
if quantity <= 0 or self.infinite: if quantity <= 0:
return False return False
if self.updateQuantity(self.quantity - quantity): if self.updateQuantity(self.quantity - quantity):

View File

@ -382,6 +382,7 @@ function bomItemFields() {
note: {}, note: {},
allow_variants: {}, allow_variants: {},
inherited: {}, inherited: {},
consumable: {},
optional: {}, optional: {},
}; };
@ -761,7 +762,22 @@ function loadBomTable(table, options={}) {
} }
return available; 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 // Construct the table columns
@ -844,6 +860,9 @@ function loadBomTable(table, options={}) {
{ {
field: 'sub_part_detail.description', field: 'sub_part_detail.description',
title: '{% trans "Description" %}', title: '{% trans "Description" %}',
formatter: function(value) {
return withTitle(shortenString(value), value);
}
} }
); );
@ -872,8 +891,12 @@ function loadBomTable(table, options={}) {
text += ` <small>${row.sub_part_detail.units}</small>`; text += ` <small>${row.sub_part_detail.units}</small>`;
} }
if (row.consumable) {
text += ` <small>({% trans "Consumable" %})</small>`;
}
if (row.optional) { if (row.optional) {
text += ' ({% trans "Optional" %})'; text += ' <small>({% trans "Optional" %})</small>';
} }
if (row.overage) { if (row.overage) {
@ -966,40 +989,11 @@ function loadBomTable(table, options={}) {
if (row.substitutes && row.substitutes.length > 0) { if (row.substitutes && row.substitutes.length > 0) {
return row.substitutes.length; return row.substitutes.length;
} else { } 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 `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
}
}
});
}
cols.push({ cols.push({
field: 'optional', field: 'optional',
title: '{% trans "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({ cols.push({
field: 'allow_variants', field: 'allow_variants',
title: '{% trans "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 `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
}
}
});
}
cols.push( cols.push(
{ {
field: 'can_build', field: 'can_build',
title: '{% trans "Can Build" %}', title: '{% trans "Can Build" %}',
sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
var can_build = 0;
var available = availableQuantity(row); // "Consumable" parts are not tracked in the build
if (row.consumable) {
if (row.quantity > 0) { return `<em>{% trans "Consumable item" %}</em>`;
can_build = available / row.quantity;
} }
var text = formatDecimal(can_build, 2); var can_build = canBuildQuantity(row);
// Take "on order" quantity into account return +can_build.toFixed(2);
if (row.on_order && row.on_order > 0 && row.quantity > 0) { },
available += row.on_order; sorter: function(valA, valB, rowA, rowB) {
can_build = available / row.quantity; // Function to sort the "can build" quantity
var cb_a = canBuildQuantity(rowA);
var cb_b = canBuildQuantity(rowB);
text += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Including On Order" %}: ${formatDecimal(can_build, 2)}'></span>`; return (cb_a > cb_b) ? 1 : -1;
}
return text;
}, },
footerFormatter: function(data) { footerFormatter: function(data) {
var can_build = null; var can_build = null;
data.forEach(function(row) { 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; var cb = availableQuantity(row) / row.quantity;
if (can_build == null || cb < can_build) { if (can_build == null || cb < can_build) {
@ -1080,23 +1110,7 @@ function loadBomTable(table, options={}) {
} else { } else {
return formatDecimal(can_build, 2); 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" %}', title: '{% trans "Notes" %}',
searchable: true, searchable: true,
sortable: true, sortable: true,
formatter: function(value) {
return withTitle(shortenString(value), value);
}
} }
); );

View File

@ -1859,33 +1859,37 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var icons = ''; var icons = '';
if (available_stock < (required - allocated)) { if (row.consumable) {
icons += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`; icons += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Consumable item" %}'></span>`;
} else { } else {
icons += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`; if (available_stock < (required - allocated)) {
icons += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`;
} else {
icons += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`;
}
if (available_stock <= 0) {
icons += `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} 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 += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
}
} }
if (row.on_order && row.on_order > 0) { if (row.on_order && row.on_order > 0) {
icons += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`; icons += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
} }
if (available_stock <= 0) {
icons += `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} 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 += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
}
return renderLink(text, url) + icons; return renderLink(text, url) + icons;
}, },
sorter: function(valA, valB, rowA, rowB) { sorter: function(valA, valB, rowA, rowB) {
@ -1898,8 +1902,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
title: '{% trans "Allocated" %}', title: '{% trans "Allocated" %}',
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
var allocated = allocatedQuantity(row);
var required = requiredQuantity(row); var required = requiredQuantity(row);
var allocated = row.consumable ? required : allocatedQuantity(row);
return makeProgressBar(allocated, required); return makeProgressBar(allocated, required);
}, },
sorter: function(valA, valB, rowA, rowB) { sorter: function(valA, valB, rowA, rowB) {
@ -1938,6 +1942,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
field: 'actions', field: 'actions',
title: '{% trans "Actions" %}', title: '{% trans "Actions" %}',
formatter: function(value, row) { formatter: function(value, row) {
if (row.consumable) {
return `<em>{% trans "Consumable item" %}</em>`;
}
// Generate action buttons for this build output // Generate action buttons for this build output
var html = `<div class='btn-group float-right' role='group'>`; var html = `<div class='btn-group float-right' role='group'>`;
@ -2093,6 +2102,11 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
for (var idx = 0; idx < bom_items.length; idx++) { for (var idx = 0; idx < bom_items.length; idx++) {
var bom_item = bom_items[idx]; var bom_item = bom_items[idx];
// Ignore "consumable" BOM items
if (bom_item.consumable) {
continue;
}
var required = bom_item.required || 0; var required = bom_item.required || 0;
var allocated = bom_item.allocated || 0; var allocated = bom_item.allocated || 0;
var remaining = required - allocated; var remaining = required - allocated;

View File

@ -79,6 +79,14 @@ function getAvailableTableFilters(tableKey) {
type: 'bool', type: 'bool',
title: '{% trans "Allow Variant Stock" %}', title: '{% trans "Allow Variant Stock" %}',
}, },
optional: {
type: 'bool',
title: '{% trans "Optional" %}',
},
consumable: {
type: 'bool',
title: '{% trans "Consumable" %}',
},
}; };
} }