From d692c18274840bf9647e085b116e4e96b2a5aa10 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Feb 2021 21:53:15 +1100 Subject: [PATCH 1/8] Add 'inherited' field to BomItem --- .../part/migrations/0063_bomitem_inherited.py | 18 ++++++++++++++++++ InvenTree/part/models.py | 7 +++++++ 2 files changed, 25 insertions(+) create mode 100644 InvenTree/part/migrations/0063_bomitem_inherited.py diff --git a/InvenTree/part/migrations/0063_bomitem_inherited.py b/InvenTree/part/migrations/0063_bomitem_inherited.py new file mode 100644 index 0000000000..569236fd72 --- /dev/null +++ b/InvenTree/part/migrations/0063_bomitem_inherited.py @@ -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'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 0257caee0c..5fba030f81 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1977,6 +1977,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 +2017,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: From 40d75090a7f2cf472b194baf51ce52745a4dadd3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Feb 2021 21:53:26 +1100 Subject: [PATCH 2/8] Add 'inherited' flag to API --- InvenTree/part/api.py | 8 ++++++++ InvenTree/part/serializers.py | 11 ++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 3c848620d5..368dee2f1a 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -810,6 +810,14 @@ 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) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 05fc3091f7..103d0202f1 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -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', ] From 43eba3f7ecb229374adf797936bb42685edc8f23 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Feb 2021 22:05:17 +1100 Subject: [PATCH 3/8] Add ability to include bom items inherited from parent parts in the API list --- InvenTree/part/api.py | 27 ++++++++++++++++++++++++- InvenTree/part/forms.py | 1 + InvenTree/templates/js/bom.js | 12 +++++++++++ InvenTree/templates/js/table_filters.js | 4 ++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 368dee2f1a..0d85b533f8 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -822,7 +822,32 @@ class BomList(generics.ListCreateAPIView): 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) + + # Construct a filter for matching the provided part + local_part_filter = Q(part=part) + + # Construct a filter for matching inherited items from parent parts + parent_parts = part.get_ancestors(include_self=False) + parent_ids = [p.pk for p in parent_parts] + + parent_part_filter = Q(part__pk__in=parent_ids, inherited=True) + + queryset = queryset.filter(local_part_filter | parent_part_filter) + + except (ValueError, Part.DoesNotExist): + pass # Filter by sub-part? sub_part = params.get('sub_part', None) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 16518937ae..85a851e235 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -331,6 +331,7 @@ class EditBomItemForm(HelperForm): 'reference', 'overage', 'note', + 'inherited', 'optional', ] diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js index 50608e4053..bbaca2c04b 100644 --- a/InvenTree/templates/js/bom.js +++ b/InvenTree/templates/js/bom.js @@ -254,6 +254,18 @@ function loadBomTable(table, options) { }); */ + cols.push({ + field: 'optional', + title: '{% trans "Optional" %}', + searchable: false, + }); + + cols.push({ + field: 'inherited', + title: '{% trans "Inherited" %}', + searchable: false, + }); + cols.push( { 'field': 'can_build', diff --git a/InvenTree/templates/js/table_filters.js b/InvenTree/templates/js/table_filters.js index 81f72fb26d..4c802446fc 100644 --- a/InvenTree/templates/js/table_filters.js +++ b/InvenTree/templates/js/table_filters.js @@ -44,6 +44,10 @@ function getAvailableTableFilters(tableKey) { type: 'bool', title: '{% trans "Validated" %}', }, + inherited: { + type: 'bool', + title: '{% trans "Inherited" %}', + } }; } From 5b402b6bc0cff818e824f3b7d34d0f62ca6f24ff Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Feb 2021 22:18:32 +1100 Subject: [PATCH 4/8] BOM table formatting - Display link to external BOM - Prevent item from being edited to selected --- InvenTree/templates/js/bom.js | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js index bbaca2c04b..8ab1b03227 100644 --- a/InvenTree/templates/js/bom.js +++ b/InvenTree/templates/js/bom.js @@ -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; + } + } }); } @@ -264,6 +274,20 @@ function loadBomTable(table, options) { 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 "Inheritable" %}'; + } else { + // If this BOM item is inherited from a parent part + return renderLink( + '{% trans "View BOM" %}', + `/part/${row.part}/bom/`, + ); + } + } }); cols.push( @@ -342,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/` + ); } } }); From 1eb2456e3d6d6ba9eeefdc5df2b5820d418c9e61 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Feb 2021 22:25:43 +1100 Subject: [PATCH 5/8] Display inherited rows a bit differenter --- InvenTree/InvenTree/static/css/inventree.css | 5 ++++ InvenTree/part/templates/part/bom.html | 4 +--- InvenTree/templates/js/bom.js | 25 +++++++++++++------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index a8c7503680..2319c2b9f7 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -307,6 +307,11 @@ font-style: italic; } +.rowinherited { + background-color: #efe; + opacity: 90%; +} + .dropdown { padding-left: 1px; margin-left: 1px; diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index d8e8854791..ca0446378c 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -72,11 +72,9 @@ - +
-
- {% endblock %} {% block js_load %} diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js index 8ab1b03227..b7f17bb519 100644 --- a/InvenTree/templates/js/bom.js +++ b/InvenTree/templates/js/bom.js @@ -420,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" %}'; From bb3440a8a469352235258b5ce48d2c29166dbd97 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Feb 2021 22:53:56 +1100 Subject: [PATCH 6/8] Refactor bom item filter - Also updates a number of part functions to make use of inherited BOM items --- InvenTree/InvenTree/static/css/inventree.css | 3 +- InvenTree/part/api.py | 11 +-- InvenTree/part/models.py | 90 +++++++++++++++++--- InvenTree/report/models.py | 1 + InvenTree/templates/js/bom.js | 2 +- 5 files changed, 80 insertions(+), 27 deletions(-) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 2319c2b9f7..50a24aa095 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -308,8 +308,7 @@ } .rowinherited { - background-color: #efe; - opacity: 90%; + background-color: #dde; } .dropdown { diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 0d85b533f8..1fd5092f14 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -835,16 +835,7 @@ class BomList(generics.ListCreateAPIView): try: part = Part.objects.get(pk=part) - # Construct a filter for matching the provided part - local_part_filter = Q(part=part) - - # Construct a filter for matching inherited items from parent parts - parent_parts = part.get_ancestors(include_self=False) - parent_ids = [p.pk for p in parent_parts] - - parent_part_filter = Q(part__pk__in=parent_ids, inherited=True) - - queryset = queryset.filter(local_part_filter | parent_part_filter) + queryset = queryset.filter(part.get_bom_item_filter()) except (ValueError, Part.DoesNotExist): pass diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 5fba030f81..99c480a3b3 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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! diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 83810f7344..3cab0d5346 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -372,6 +372,7 @@ class BillOfMaterialsReport(ReportTemplateBase): return { 'part': part, 'category': part.category, + 'bom_items': part.get_bom_items(), } diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js index b7f17bb519..76c65d44dd 100644 --- a/InvenTree/templates/js/bom.js +++ b/InvenTree/templates/js/bom.js @@ -279,7 +279,7 @@ function loadBomTable(table, options) { if (!row.inherited) { return "-"; } else if (row.part == options.parent_id) { - return '{% trans "Inheritable" %}'; + return '{% trans "Inherited" %}'; } else { // If this BOM item is inherited from a parent part return renderLink( From ef902fc313f220660bc517c92ad1b1117308275d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Feb 2021 23:27:09 +1100 Subject: [PATCH 7/8] Add bom_items to build order report context --- InvenTree/report/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 3cab0d5346..4ab6a25bf4 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -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, } From 3f30421ba9bcce0c3bf88ac311b74cf4ff8e8d5b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Feb 2021 23:57:45 +1100 Subject: [PATCH 8/8] bug fix --- InvenTree/part/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 99c480a3b3..911a2cdac4 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1451,7 +1451,7 @@ class Part(MPTTModel): min_price = None max_price = None - for item in self.get_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")