Merge pull request #1313 from SchrodingersGat/inherited-bom-items

Inherited bom items
This commit is contained in:
Oliver 2021-02-18 00:30:52 +11:00 committed by GitHub
commit bf63005731
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 203 additions and 32 deletions

View File

@ -307,6 +307,10 @@
font-style: italic;
}
.rowinherited {
background-color: #dde;
}
.dropdown {
padding-left: 1px;
margin-left: 1px;

View File

@ -810,11 +810,35 @@ class BomList(generics.ListCreateAPIView):
queryset = queryset.filter(optional=optional)
# Filter by "inherited" status
inherited = params.get('inherited', None)
if inherited is not None:
inherited = str2bool(inherited)
queryset = queryset.filter(inherited=inherited)
# Filter by part?
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(part=part)
"""
If we are filtering by "part", there are two cases to consider:
a) Bom items which are defined for *this* part
b) Inherited parts which are defined for a *parent* part
So we need to construct two queries!
"""
# First, check that the part is actually valid!
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(part.get_bom_item_filter())
except (ValueError, Part.DoesNotExist):
pass
# Filter by sub-part?
sub_part = params.get('sub_part', None)

View File

@ -331,6 +331,7 @@ class EditBomItemForm(HelperForm):
'reference',
'overage',
'note',
'inherited',
'optional',
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2021-02-17 10:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0062_merge_20210105_0056'),
]
operations = [
migrations.AddField(
model_name='bomitem',
name='inherited',
field=models.BooleanField(default=False, help_text='This BOM item is inherited by BOMs for variant parts', verbose_name='Inherited'),
),
]

View File

@ -14,7 +14,7 @@ from django.urls import reverse
from django.db import models, transaction
from django.db.utils import IntegrityError
from django.db.models import Sum, UniqueConstraint
from django.db.models import Q, Sum, UniqueConstraint
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
@ -418,8 +418,10 @@ class Part(MPTTModel):
p2=str(parent)
))})
bom_items = self.get_bom_items()
# Ensure that the parent part does not appear under any child BOM item!
for item in self.bom_items.all():
for item in bom_items.all():
# Check for simple match
if item.sub_part == parent:
@ -1058,8 +1060,10 @@ class Part(MPTTModel):
total = None
bom_items = self.get_bom_items().prefetch_related('sub_part__stock_items')
# Calculate the minimum number of parts that can be built using each sub-part
for item in self.bom_items.all().prefetch_related('sub_part__stock_items'):
for item in bom_items.all():
stock = item.sub_part.available_stock
# If (by some chance) we get here but the BOM item quantity is invalid,
@ -1189,9 +1193,56 @@ class Part(MPTTModel):
return query['t']
def get_bom_item_filter(self, include_inherited=True):
"""
Returns a query filter for all BOM items associated with this Part.
There are some considerations:
a) BOM items can be defined against *this* part
b) BOM items can be inherited from a *parent* part
We will construct a filter to grab *all* the BOM items!
Note: This does *not* return a queryset, it returns a Q object,
which can be used by some other query operation!
Because we want to keep our code DRY!
"""
bom_filter = Q(part=self)
if include_inherited:
# We wish to include parent parts
parents = self.get_ancestors(include_self=False)
# There are parents available
if parents.count() > 0:
parent_ids = [p.pk for p in parents]
parent_filter = Q(
part__id__in=parent_ids,
inherited=True
)
# OR the filters together
bom_filter |= parent_filter
return bom_filter
def get_bom_items(self, include_inherited=True):
"""
Return a queryset containing all BOM items for this part
By default, will include inherited BOM items
"""
return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
@property
def has_bom(self):
return self.bom_count > 0
return self.get_bom_items().count() > 0
@property
def has_trackable_parts(self):
@ -1200,7 +1251,7 @@ class Part(MPTTModel):
This is important when building the part.
"""
for bom_item in self.bom_items.all():
for bom_item in self.get_bom_items().all():
if bom_item.sub_part.trackable:
return True
@ -1209,7 +1260,7 @@ class Part(MPTTModel):
@property
def bom_count(self):
""" Return the number of items contained in the BOM for this part """
return self.bom_items.count()
return self.get_bom_items().count()
@property
def used_in_count(self):
@ -1227,7 +1278,10 @@ class Part(MPTTModel):
hash = hashlib.md5(str(self.id).encode())
for item in self.bom_items.all().prefetch_related('sub_part'):
# List *all* BOM items (including inherited ones!)
bom_items = self.get_bom_items().all().prefetch_related('sub_part')
for item in bom_items:
hash.update(str(item.get_item_hash()).encode())
return str(hash.digest())
@ -1246,8 +1300,10 @@ class Part(MPTTModel):
- Saves the current date and the checking user
"""
# Validate each line item too
for item in self.bom_items.all():
# Validate each line item, ignoring inherited ones
bom_items = self.get_bom_items(include_inherited=False)
for item in bom_items.all():
item.validate_hash()
self.bom_checksum = self.get_bom_hash()
@ -1258,7 +1314,10 @@ class Part(MPTTModel):
@transaction.atomic
def clear_bom(self):
""" Clear the BOM items for the part (delete all BOM lines).
"""
Clear the BOM items for the part (delete all BOM lines).
Note: Does *NOT* delete inherited BOM items!
"""
self.bom_items.all().delete()
@ -1275,9 +1334,9 @@ class Part(MPTTModel):
if parts is None:
parts = set()
items = BomItem.objects.filter(part=self.pk)
bom_items = self.get_bom_items().all()
for bom_item in items:
for bom_item in bom_items:
sub_part = bom_item.sub_part
@ -1325,7 +1384,7 @@ class Part(MPTTModel):
def has_complete_bom_pricing(self):
""" Return true if there is pricing information for each item in the BOM. """
for item in self.bom_items.all().select_related('sub_part'):
for item in self.get_bom_items().all().select_related('sub_part'):
if not item.sub_part.has_pricing_info:
return False
@ -1392,7 +1451,7 @@ class Part(MPTTModel):
min_price = None
max_price = None
for item in self.bom_items.all().select_related('sub_part'):
for item in self.get_bom_items().all().select_related('sub_part'):
if item.sub_part.pk == self.pk:
print("Warning: Item contains itself in BOM")
@ -1460,8 +1519,11 @@ class Part(MPTTModel):
if clear:
# Remove existing BOM items
# Note: Inherited BOM items are *not* deleted!
self.bom_items.all().delete()
# Copy existing BOM items from another part
# Note: Inherited BOM Items will *not* be duplicated!!
for bom_item in other.bom_items.all():
# If this part already has a BomItem pointing to the same sub-part,
# delete that BomItem from this part first!
@ -1977,6 +2039,7 @@ class BomItem(models.Model):
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
checksum: Validation checksum for the particular BOM line item
inherited: This BomItem can be inherited by the BOMs of variant parts
"""
def save(self, *args, **kwargs):
@ -2016,6 +2079,12 @@ class BomItem(models.Model):
checksum = models.CharField(max_length=128, blank=True, help_text=_('BOM line checksum'))
inherited = models.BooleanField(
default=False,
verbose_name=_('Inherited'),
help_text=_('This BOM item is inherited by BOMs for variant parts'),
)
def get_item_hash(self):
""" Calculate the checksum hash of this BOM line item:

View File

@ -381,17 +381,18 @@ class BomItemSerializer(InvenTreeModelSerializer):
class Meta:
model = BomItem
fields = [
'inherited',
'note',
'optional',
'overage',
'pk',
'part',
'part_detail',
'sub_part',
'sub_part_detail',
'quantity',
'reference',
'sub_part',
'sub_part_detail',
# 'price_range',
'optional',
'overage',
'note',
'validated',
]

View File

@ -72,11 +72,9 @@
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
<table class='table table-bom table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
</table>
<table class='table table-striped table-condensed' id='test-table'></table>
{% endblock %}
{% block js_load %}

View File

@ -341,6 +341,7 @@ class BuildReport(ReportTemplateBase):
return {
'build': my_build,
'part': my_build.part,
'bom_items': my_build.part.get_bom_items(),
'reference': my_build.reference,
'quantity': my_build.quantity,
}
@ -372,6 +373,7 @@ class BillOfMaterialsReport(ReportTemplateBase):
return {
'part': part,
'category': part.category,
'bom_items': part.get_bom_items(),
}

View File

@ -137,6 +137,16 @@ function loadBomTable(table, options) {
checkbox: true,
visible: true,
switchable: false,
formatter: function(value, row, index, field) {
// Disable checkbox if the row is defined for a *different* part!
if (row.part != options.parent_id) {
return {
disabled: true,
};
} else {
return value;
}
}
});
}
@ -254,6 +264,32 @@ function loadBomTable(table, options) {
});
*/
cols.push({
field: 'optional',
title: '{% trans "Optional" %}',
searchable: false,
});
cols.push({
field: 'inherited',
title: '{% trans "Inherited" %}',
searchable: false,
formatter: function(value, row, index, field) {
// This BOM item *is* inheritable, but is defined for this BOM
if (!row.inherited) {
return "-";
} else if (row.part == options.parent_id) {
return '{% trans "Inherited" %}';
} else {
// If this BOM item is inherited from a parent part
return renderLink(
'{% trans "View BOM" %}',
`/part/${row.part}/bom/`,
);
}
}
});
cols.push(
{
'field': 'can_build',
@ -330,7 +366,12 @@ function loadBomTable(table, options) {
return html;
} else {
return '';
// Return a link to the external BOM
return renderLink(
'{% trans "View BOM" %}',
`/part/${row.part}/bom/`
);
}
}
});
@ -379,15 +420,24 @@ function loadBomTable(table, options) {
sortable: true,
search: true,
rowStyle: function(row, index) {
if (row.validated) {
return {
classes: 'rowvalid'
};
} else {
return {
classes: 'rowinvalid'
};
var classes = [];
// Shade rows differently if they are for different parent parts
if (row.part != options.parent_id) {
classes.push('rowinherited');
}
if (row.validated) {
classes.push('rowvalid');
} else {
classes.push('rowinvalid');
}
return {
classes: classes.join(' '),
};
},
formatNoMatches: function() {
return '{% trans "No BOM items found" %}';

View File

@ -44,6 +44,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Validated" %}',
},
inherited: {
type: 'bool',
title: '{% trans "Inherited" %}',
}
};
}