mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 20:16:44 +00:00
Merge pull request #1313 from SchrodingersGat/inherited-bom-items
Inherited bom items
This commit is contained in:
commit
bf63005731
@ -307,6 +307,10 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rowinherited {
|
||||||
|
background-color: #dde;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown {
|
.dropdown {
|
||||||
padding-left: 1px;
|
padding-left: 1px;
|
||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
|
@ -810,11 +810,35 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = queryset.filter(optional=optional)
|
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?
|
# Filter by part?
|
||||||
part = params.get('part', None)
|
part = params.get('part', None)
|
||||||
|
|
||||||
if part is not 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?
|
# Filter by sub-part?
|
||||||
sub_part = params.get('sub_part', None)
|
sub_part = params.get('sub_part', None)
|
||||||
|
@ -331,6 +331,7 @@ class EditBomItemForm(HelperForm):
|
|||||||
'reference',
|
'reference',
|
||||||
'overage',
|
'overage',
|
||||||
'note',
|
'note',
|
||||||
|
'inherited',
|
||||||
'optional',
|
'optional',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
18
InvenTree/part/migrations/0063_bomitem_inherited.py
Normal file
18
InvenTree/part/migrations/0063_bomitem_inherited.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -14,7 +14,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.utils import IntegrityError
|
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.db.models.functions import Coalesce
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
|
|
||||||
@ -418,8 +418,10 @@ class Part(MPTTModel):
|
|||||||
p2=str(parent)
|
p2=str(parent)
|
||||||
))})
|
))})
|
||||||
|
|
||||||
|
bom_items = self.get_bom_items()
|
||||||
|
|
||||||
# Ensure that the parent part does not appear under any child BOM item!
|
# 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
|
# Check for simple match
|
||||||
if item.sub_part == parent:
|
if item.sub_part == parent:
|
||||||
@ -1058,8 +1060,10 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
total = None
|
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
|
# 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
|
stock = item.sub_part.available_stock
|
||||||
|
|
||||||
# If (by some chance) we get here but the BOM item quantity is invalid,
|
# If (by some chance) we get here but the BOM item quantity is invalid,
|
||||||
@ -1189,9 +1193,56 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return query['t']
|
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
|
@property
|
||||||
def has_bom(self):
|
def has_bom(self):
|
||||||
return self.bom_count > 0
|
return self.get_bom_items().count() > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_trackable_parts(self):
|
def has_trackable_parts(self):
|
||||||
@ -1200,7 +1251,7 @@ class Part(MPTTModel):
|
|||||||
This is important when building the part.
|
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:
|
if bom_item.sub_part.trackable:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -1209,7 +1260,7 @@ class Part(MPTTModel):
|
|||||||
@property
|
@property
|
||||||
def bom_count(self):
|
def bom_count(self):
|
||||||
""" Return the number of items contained in the BOM for this part """
|
""" Return the number of items contained in the BOM for this part """
|
||||||
return self.bom_items.count()
|
return self.get_bom_items().count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def used_in_count(self):
|
def used_in_count(self):
|
||||||
@ -1227,7 +1278,10 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
hash = hashlib.md5(str(self.id).encode())
|
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())
|
hash.update(str(item.get_item_hash()).encode())
|
||||||
|
|
||||||
return str(hash.digest())
|
return str(hash.digest())
|
||||||
@ -1246,8 +1300,10 @@ class Part(MPTTModel):
|
|||||||
- Saves the current date and the checking user
|
- Saves the current date and the checking user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Validate each line item too
|
# Validate each line item, ignoring inherited ones
|
||||||
for item in self.bom_items.all():
|
bom_items = self.get_bom_items(include_inherited=False)
|
||||||
|
|
||||||
|
for item in bom_items.all():
|
||||||
item.validate_hash()
|
item.validate_hash()
|
||||||
|
|
||||||
self.bom_checksum = self.get_bom_hash()
|
self.bom_checksum = self.get_bom_hash()
|
||||||
@ -1258,7 +1314,10 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def clear_bom(self):
|
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()
|
self.bom_items.all().delete()
|
||||||
@ -1275,9 +1334,9 @@ class Part(MPTTModel):
|
|||||||
if parts is None:
|
if parts is None:
|
||||||
parts = set()
|
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
|
sub_part = bom_item.sub_part
|
||||||
|
|
||||||
@ -1325,7 +1384,7 @@ class Part(MPTTModel):
|
|||||||
def has_complete_bom_pricing(self):
|
def has_complete_bom_pricing(self):
|
||||||
""" Return true if there is pricing information for each item in the BOM. """
|
""" 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:
|
if not item.sub_part.has_pricing_info:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -1392,7 +1451,7 @@ class Part(MPTTModel):
|
|||||||
min_price = None
|
min_price = None
|
||||||
max_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:
|
if item.sub_part.pk == self.pk:
|
||||||
print("Warning: Item contains itself in BOM")
|
print("Warning: Item contains itself in BOM")
|
||||||
@ -1460,8 +1519,11 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
if clear:
|
if clear:
|
||||||
# Remove existing BOM items
|
# Remove existing BOM items
|
||||||
|
# Note: Inherited BOM items are *not* deleted!
|
||||||
self.bom_items.all().delete()
|
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():
|
for bom_item in other.bom_items.all():
|
||||||
# If this part already has a BomItem pointing to the same sub-part,
|
# If this part already has a BomItem pointing to the same sub-part,
|
||||||
# delete that BomItem from this part first!
|
# 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%')
|
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
|
||||||
checksum: Validation checksum for the particular BOM line 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):
|
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'))
|
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):
|
def get_item_hash(self):
|
||||||
""" Calculate the checksum hash of this BOM line item:
|
""" Calculate the checksum hash of this BOM line item:
|
||||||
|
|
||||||
|
@ -381,17 +381,18 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = BomItem
|
model = BomItem
|
||||||
fields = [
|
fields = [
|
||||||
|
'inherited',
|
||||||
|
'note',
|
||||||
|
'optional',
|
||||||
|
'overage',
|
||||||
'pk',
|
'pk',
|
||||||
'part',
|
'part',
|
||||||
'part_detail',
|
'part_detail',
|
||||||
'sub_part',
|
|
||||||
'sub_part_detail',
|
|
||||||
'quantity',
|
'quantity',
|
||||||
'reference',
|
'reference',
|
||||||
|
'sub_part',
|
||||||
|
'sub_part_detail',
|
||||||
# 'price_range',
|
# 'price_range',
|
||||||
'optional',
|
|
||||||
'overage',
|
|
||||||
'note',
|
|
||||||
'validated',
|
'validated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -72,11 +72,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' id='test-table'></table>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_load %}
|
{% block js_load %}
|
||||||
|
@ -341,6 +341,7 @@ class BuildReport(ReportTemplateBase):
|
|||||||
return {
|
return {
|
||||||
'build': my_build,
|
'build': my_build,
|
||||||
'part': my_build.part,
|
'part': my_build.part,
|
||||||
|
'bom_items': my_build.part.get_bom_items(),
|
||||||
'reference': my_build.reference,
|
'reference': my_build.reference,
|
||||||
'quantity': my_build.quantity,
|
'quantity': my_build.quantity,
|
||||||
}
|
}
|
||||||
@ -372,6 +373,7 @@ class BillOfMaterialsReport(ReportTemplateBase):
|
|||||||
return {
|
return {
|
||||||
'part': part,
|
'part': part,
|
||||||
'category': part.category,
|
'category': part.category,
|
||||||
|
'bom_items': part.get_bom_items(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -137,6 +137,16 @@ function loadBomTable(table, options) {
|
|||||||
checkbox: true,
|
checkbox: true,
|
||||||
visible: true,
|
visible: true,
|
||||||
switchable: false,
|
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(
|
cols.push(
|
||||||
{
|
{
|
||||||
'field': 'can_build',
|
'field': 'can_build',
|
||||||
@ -330,7 +366,12 @@ function loadBomTable(table, options) {
|
|||||||
|
|
||||||
return html;
|
return html;
|
||||||
} else {
|
} 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,
|
sortable: true,
|
||||||
search: true,
|
search: true,
|
||||||
rowStyle: function(row, index) {
|
rowStyle: function(row, index) {
|
||||||
if (row.validated) {
|
|
||||||
return {
|
var classes = [];
|
||||||
classes: 'rowvalid'
|
|
||||||
};
|
// Shade rows differently if they are for different parent parts
|
||||||
} else {
|
if (row.part != options.parent_id) {
|
||||||
return {
|
classes.push('rowinherited');
|
||||||
classes: 'rowinvalid'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (row.validated) {
|
||||||
|
classes.push('rowvalid');
|
||||||
|
} else {
|
||||||
|
classes.push('rowinvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
classes: classes.join(' '),
|
||||||
|
};
|
||||||
|
|
||||||
},
|
},
|
||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
return '{% trans "No BOM items found" %}';
|
return '{% trans "No BOM items found" %}';
|
||||||
|
@ -44,6 +44,10 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Validated" %}',
|
title: '{% trans "Validated" %}',
|
||||||
},
|
},
|
||||||
|
inherited: {
|
||||||
|
type: 'bool',
|
||||||
|
title: '{% trans "Inherited" %}',
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user