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:
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:

View File

@ -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):

View File

@ -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(

View File

@ -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'

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
# 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):

View File

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

View File

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

View File

@ -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)

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)
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):

View File

@ -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 += ` <small>${row.sub_part_detail.units}</small>`;
}
if (row.consumable) {
text += ` <small>({% trans "Consumable" %})</small>`;
}
if (row.optional) {
text += ' ({% trans "Optional" %})';
text += ' <small>({% trans "Optional" %})</small>';
}
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 `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
}
}
});
}
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 `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
}
}
});
}
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 `<em>{% trans "Consumable item" %}</em>`;
}
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 += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Including On Order" %}: ${formatDecimal(can_build, 2)}'></span>`;
}
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);
}
}
);

View File

@ -1859,16 +1859,15 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var icons = '';
if (row.consumable) {
icons += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Consumable item" %}'></span>`;
} else {
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 (row.on_order && row.on_order > 0) {
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 {
@ -1885,6 +1884,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
icons += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
}
}
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>`;
}
return renderLink(text, url) + icons;
},
@ -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 `<em>{% trans "Consumable item" %}</em>`;
}
// Generate action buttons for this build output
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++) {
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;

View File

@ -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" %}',
},
};
}