Merge pull request #2150 from SchrodingersGat/bom-substitutes

Bom substitutes
This commit is contained in:
Oliver 2021-10-14 11:24:46 +11:00 committed by GitHub
commit 22572c6f35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1022 additions and 390 deletions

View File

@ -455,6 +455,10 @@
-webkit-opacity: 10%;
}
.table-condensed {
font-size: 90%;
}
/* grid display for part images */
.table-img-grid tr {

View File

@ -21,7 +21,7 @@ from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem, BuildOrderAttachment
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
from .serializers import BuildAllocationSerializer
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
class BuildFilter(rest_filters.FilterSet):
@ -184,6 +184,42 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
serializer_class = BuildSerializer
class BuildUnallocate(generics.CreateAPIView):
"""
API endpoint for unallocating stock items from a build order
- The BuildOrder object is specified by the URL
- "output" (StockItem) can optionally be specified
- "bom_item" can optionally be specified
"""
queryset = Build.objects.none()
serializer_class = BuildUnallocationSerializer
def get_build(self):
"""
Returns the BuildOrder associated with this API endpoint
"""
pk = self.kwargs.get('pk', None)
try:
build = Build.objects.get(pk=pk)
except (ValueError, Build.DoesNotExist):
raise ValidationError(_("Matching build order does not exist"))
return build
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['build'] = self.get_build()
ctx['request'] = self.request
return ctx
class BuildAllocate(generics.CreateAPIView):
"""
API endpoint to allocate stock items to a build order
@ -349,6 +385,7 @@ build_api_urls = [
# Build Detail
url(r'^(?P<pk>\d+)/', include([
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])),

View File

@ -137,32 +137,6 @@ class BuildOutputDeleteForm(HelperForm):
]
class UnallocateBuildForm(HelperForm):
"""
Form for auto-de-allocation of stock from a build
"""
confirm = forms.BooleanField(required=False, label=_('Confirm'), help_text=_('Confirm unallocation of stock'))
output_id = forms.IntegerField(
required=False,
widget=forms.HiddenInput()
)
part_id = forms.IntegerField(
required=False,
widget=forms.HiddenInput(),
)
class Meta:
model = Build
fields = [
'confirm',
'output_id',
'part_id',
]
class CompleteBuildForm(HelperForm):
"""
Form for marking a build as complete

View File

@ -587,9 +587,13 @@ class Build(MPTTModel):
self.save()
@transaction.atomic
def unallocateOutput(self, output, part=None):
def unallocateStock(self, bom_item=None, output=None):
"""
Unallocate all stock which are allocated against the provided "output" (StockItem)
Unallocate stock from this Build
arguments:
- bom_item: Specify a particular BomItem to unallocate stock against
- output: Specify a particular StockItem (output) to unallocate stock against
"""
allocations = BuildItem.objects.filter(
@ -597,34 +601,8 @@ class Build(MPTTModel):
install_into=output
)
if part:
allocations = allocations.filter(stock_item__part=part)
allocations.delete()
@transaction.atomic
def unallocateUntracked(self, part=None):
"""
Unallocate all "untracked" stock
"""
allocations = BuildItem.objects.filter(
build=self,
install_into=None
)
if part:
allocations = allocations.filter(stock_item__part=part)
allocations.delete()
@transaction.atomic
def unallocateAll(self):
"""
Deletes all stock allocations for this build.
"""
allocations = BuildItem.objects.filter(build=self)
if bom_item:
allocations = allocations.filter(bom_item=bom_item)
allocations.delete()
@ -720,7 +698,7 @@ class Build(MPTTModel):
raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output
self.unallocateOutput(output)
self.unallocateStock(output=output)
# Remove the build output from the database
output.delete()
@ -1153,16 +1131,12 @@ class BuildItem(models.Model):
i) The sub_part points to the same part as the referenced StockItem
ii) The BomItem allows variants and the part referenced by the StockItem
is a variant of the sub_part referenced by the BomItem
iii) The Part referenced by the StockItem is a valid substitute for the BomItem
"""
if self.build and self.build.part == self.bom_item.part:
# Check that the sub_part points to the stock_item (either directly or via a variant)
if self.bom_item.sub_part == self.stock_item.part:
bom_item_valid = True
elif self.bom_item.allow_variants and self.stock_item.part in self.bom_item.sub_part.get_descendants(include_self=False):
bom_item_valid = True
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item)
# If the existing BomItem is *not* valid, try to find a match
if not bom_item_valid:

View File

@ -120,6 +120,61 @@ class BuildSerializer(InvenTreeModelSerializer):
]
class BuildUnallocationSerializer(serializers.Serializer):
"""
DRF serializer for unallocating stock from a BuildOrder
Allocated stock can be unallocated with a number of filters:
- output: Filter against a particular build output (blank = untracked stock)
- bom_item: Filter against a particular BOM line item
"""
bom_item = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('BOM Item'),
)
output = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.filter(
is_building=True,
),
many=False,
allow_null=True,
required=False,
label=_("Build output"),
)
def validate_output(self, stock_item):
# Stock item must point to the same build order!
build = self.context['build']
if stock_item and stock_item.build != build:
raise ValidationError(_("Build output must point to the same build"))
return stock_item
def save(self):
"""
'Save' the serializer data.
This performs the actual unallocation against the build order
"""
build = self.context['build']
data = self.validated_data
build.unallocateStock(
bom_item=data['bom_item'],
output=data['output']
)
class BuildAllocationItemSerializer(serializers.Serializer):
"""
A serializer for allocating a single stock item against a build order

View File

@ -197,7 +197,7 @@
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span>
</button>
<div class='filter-list' id='filter-list-build-items'>
<div class='filter-list' id='filter-list-builditems'>
<!-- Empty div for table filters-->
</div>
</div>
@ -462,12 +462,9 @@ $("#btn-auto-allocate").on('click', function() {
});
$('#btn-unallocate').on('click', function() {
launchModalForm(
"{% url 'build-unallocate' build.id %}",
{
success: reloadTable,
}
);
unallocateStock({{ build.id }}, {
table: '#allocation-table-untracked',
});
});
$('#allocate-selected-items').click(function() {

View File

@ -1,15 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
{{ block.super }}
<div class='alert alert-block alert-danger'>
{% trans "Are you sure you wish to unallocate all stock for this build?" %}
<br>
{% trans "All incomplete stock allocations will be removed from the build" %}
</div>
{% endblock %}

View File

@ -250,7 +250,7 @@ class BuildTest(TestCase):
self.assertEqual(len(unallocated), 1)
self.build.unallocateUntracked()
self.build.unallocateStock()
unallocated = self.build.unallocatedParts(None)

View File

@ -323,22 +323,3 @@ class TestBuildViews(TestCase):
b = Build.objects.get(pk=1)
self.assertEqual(b.status, 30) # Build status is now CANCELLED
def test_build_unallocate(self):
""" Test the build unallocation view (ajax form) """
url = reverse('build-unallocate', args=(1,))
# Test without confirmation
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
# Test with confirmation
response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['form_valid'])

View File

@ -12,7 +12,6 @@ build_detail_urls = [
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),

View File

@ -10,14 +10,13 @@ from django.core.exceptions import ValidationError
from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from part.models import Part
from .models import Build
from . import forms
from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, extract_serial_numbers, isNull
from InvenTree.helpers import str2bool, extract_serial_numbers
from InvenTree.status_codes import BuildStatus, StockStatus
@ -246,88 +245,6 @@ class BuildOutputDelete(AjaxUpdateView):
}
class BuildUnallocate(AjaxUpdateView):
""" View to un-allocate all parts from a build.
Provides a simple confirmation dialog with a BooleanField checkbox.
"""
model = Build
form_class = forms.UnallocateBuildForm
ajax_form_title = _("Unallocate Stock")
ajax_template_name = "build/unallocate.html"
def get_initial(self):
initials = super().get_initial()
# Pointing to a particular build output?
output = self.get_param('output')
if output:
initials['output_id'] = output
# Pointing to a particular part?
part = self.get_param('part')
if part:
initials['part_id'] = part
return initials
def post(self, request, *args, **kwargs):
build = self.get_object()
form = self.get_form()
confirm = request.POST.get('confirm', False)
output_id = request.POST.get('output_id', None)
if output_id:
# If a "null" output is provided, we are trying to unallocate "untracked" stock
if isNull(output_id):
output = None
else:
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
output = None
part_id = request.POST.get('part_id', None)
try:
part = Part.objects.get(pk=part_id)
except (ValueError, Part.DoesNotExist):
part = None
valid = False
if confirm is False:
form.add_error('confirm', _('Confirm unallocation of build stock'))
form.add_error(None, _('Check the confirmation box'))
else:
valid = True
# Unallocate the entire build
if not output_id:
build.unallocateAll()
# Unallocate a single output
elif output:
build.unallocateOutput(output, part=part)
# Unallocate "untracked" parts
else:
build.unallocateUntracked(part=part)
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data)
class BuildComplete(AjaxUpdateView):
"""
View to mark the build as complete.

View File

@ -27,7 +27,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory, BomItem
from .models import Part, PartCategory
from .models import BomItem, BomItemSubstitute
from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
@ -1078,11 +1079,23 @@ class BomList(generics.ListCreateAPIView):
queryset = self.filter_queryset(self.get_queryset())
serializer = self.get_serializer(queryset, many=True)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
if request.is_ajax():
"""
Determine the response type based on the request.
a) For HTTP requests (e.g. via the browseable API) return a DRF response
b) For AJAX requests, simply return a JSON rendered response.
"""
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
@ -1102,7 +1115,7 @@ class BomList(generics.ListCreateAPIView):
try:
# Include or exclude pricing information in the serialized data
kwargs['include_pricing'] = str2bool(self.request.GET.get('include_pricing', True))
kwargs['include_pricing'] = self.include_pricing()
except AttributeError:
pass
@ -1147,13 +1160,19 @@ class BomList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist):
pass
include_pricing = str2bool(params.get('include_pricing', True))
if include_pricing:
if self.include_pricing():
queryset = self.annotate_pricing(queryset)
return queryset
def include_pricing(self):
"""
Determine if pricing information should be included in the response
"""
pricing_default = InvenTreeSetting.get_setting('PART_SHOW_PRICE_IN_BOM')
return str2bool(self.request.query_params.get('include_pricing', pricing_default))
def annotate_pricing(self, queryset):
"""
Add part pricing information to the queryset
@ -1262,6 +1281,35 @@ class BomItemValidate(generics.UpdateAPIView):
return Response(serializer.data)
class BomItemSubstituteList(generics.ListCreateAPIView):
"""
API endpoint for accessing a list of BomItemSubstitute objects
"""
serializer_class = part_serializers.BomItemSubstituteSerializer
queryset = BomItemSubstitute.objects.all()
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'part',
'bom_item',
]
class BomItemSubstituteDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of a single BomItemSubstitute object
"""
queryset = BomItemSubstitute.objects.all()
serializer_class = part_serializers.BomItemSubstituteSerializer
part_api_urls = [
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
@ -1314,6 +1362,16 @@ part_api_urls = [
]
bom_api_urls = [
url(r'^substitute/', include([
# Detail view
url(r'^(?P<pk>\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
# Catch all
url(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
])),
# BOM Item Detail
url(r'^(?P<pk>\d+)/', include([
url(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.5 on 2021-10-12 23:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0071_alter_partparametertemplate_name'),
]
operations = [
migrations.CreateModel(
name='BomItemSubstitute',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bom_item', models.ForeignKey(help_text='Parent BOM item', on_delete=django.db.models.deletion.CASCADE, related_name='substitutes', to='part.bomitem', verbose_name='BOM Item')),
('part', models.ForeignKey(help_text='Substitute part', limit_choices_to={'component': True}, on_delete=django.db.models.deletion.CASCADE, related_name='substitute_items', to='part.part', verbose_name='Part')),
],
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.2.5 on 2021-10-13 10:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0072_bomitemsubstitute'),
]
operations = [
migrations.AlterModelOptions(
name='bomitemsubstitute',
options={'verbose_name': 'BOM Item Substitute'},
),
migrations.AlterUniqueTogether(
name='bomitemsubstitute',
unique_together={('part', 'bom_item')},
),
]

View File

@ -2333,22 +2333,48 @@ class BomItem(models.Model):
def get_api_url():
return reverse('api-bom-list')
def get_valid_parts_for_allocation(self):
"""
Return a list of valid parts which can be allocated against this BomItem:
- Include the referenced sub_part
- Include any directly specvified substitute parts
- If allow_variants is True, allow all variants of sub_part
"""
# Set of parts we will allow
parts = set()
parts.add(self.sub_part)
# Variant parts (if allowed)
if self.allow_variants:
for variant in self.sub_part.get_descendants(include_self=False):
parts.add(variant)
# Substitute parts
for sub in self.substitutes.all():
parts.add(sub.part)
return parts
def is_stock_item_valid(self, stock_item):
"""
Check if the provided StockItem object is "valid" for assignment against this BomItem
"""
return stock_item.part in self.get_valid_parts_for_allocation()
def get_stock_filter(self):
"""
Return a queryset filter for selecting StockItems which match this BomItem
- Allow stock from all directly specified substitute parts
- If allow_variants is True, allow all part variants
"""
# Target part
part = self.sub_part
if self.allow_variants:
variants = part.get_descendants(include_self=True)
return Q(part__in=[v.pk for v in variants])
else:
return Q(part=part)
return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
def save(self, *args, **kwargs):
@ -2613,6 +2639,66 @@ class BomItem(models.Model):
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
class BomItemSubstitute(models.Model):
"""
A BomItemSubstitute provides a specification for alternative parts,
which can be used in a bill of materials.
Attributes:
bom_item: Link to the parent BomItem instance
part: The part which can be used as a substitute
"""
class Meta:
verbose_name = _("BOM Item Substitute")
# Prevent duplication of substitute parts
unique_together = ('part', 'bom_item')
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def validate_unique(self, exclude=None):
"""
Ensure that this BomItemSubstitute is "unique":
- It cannot point to the same "part" as the "sub_part" of the parent "bom_item"
"""
super().validate_unique(exclude=exclude)
if self.part == self.bom_item.sub_part:
raise ValidationError({
"part": _("Substitute part cannot be the same as the master part"),
})
@staticmethod
def get_api_url():
return reverse('api-bom-substitute-list')
bom_item = models.ForeignKey(
BomItem,
on_delete=models.CASCADE,
related_name='substitutes',
verbose_name=_('BOM Item'),
help_text=_('Parent BOM item'),
)
part = models.ForeignKey(
Part,
on_delete=models.CASCADE,
related_name='substitute_items',
verbose_name=_('Part'),
help_text=_('Substitute part'),
limit_choices_to={
'component': True,
}
)
class PartRelated(models.Model):
""" Store and handle related parts (eg. mating connector, crimps, etc.) """

View File

@ -23,7 +23,8 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory,
from .models import (BomItem, BomItemSubstitute,
Part, PartAttachment, PartCategory,
PartParameter, PartParameterTemplate, PartSellPriceBreak,
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
PartInternalPriceBreak)
@ -388,8 +389,27 @@ class PartStarSerializer(InvenTreeModelSerializer):
]
class BomItemSubstituteSerializer(InvenTreeModelSerializer):
"""
Serializer for the BomItemSubstitute class
"""
part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
class Meta:
model = BomItemSubstitute
fields = [
'pk',
'bom_item',
'part',
'part_detail',
]
class BomItemSerializer(InvenTreeModelSerializer):
""" Serializer for BomItem object """
"""
Serializer for BomItem object
"""
price_range = serializers.CharField(read_only=True)
@ -397,6 +417,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
substitutes = BomItemSubstituteSerializer(many=True, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True))
@ -515,6 +537,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
'reference',
'sub_part',
'sub_part_detail',
'substitutes',
'price_range',
'validated',
]

View File

@ -1,11 +1,12 @@
{% load i18n %}
{% load inventree_extras %}
{% if roles.part.change != True and editing_enabled %}
{% if not roles.part.change %}
<div class='alert alert-danger alert-block'>
{% trans "You do not have permission to edit the BOM." %}
</div>
{% else %}
{% endif %}
{% if part.bom_checked_date %}
{% if part.is_bom_valid %}
<div class='alert alert-block alert-info'>
@ -23,42 +24,38 @@
<div id='bom-button-toolbar'>
<div class="btn-group" role="group" aria-label="...">
{% if editing_enabled %}
<button class='btn btn-default' type='button' title='{% trans "Remove selected BOM items" %}' id='bom-item-delete'>
<span class='fas fa-trash-alt icon-red'></span>
</button>
<button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
<span class='fas fa-file-upload'></span>
</button>
{% if part.variant_of %}
<button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
<span class='fas fa-clone'></span>
</button>
{% endif %}
<button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
<span class='fas fa-plus-circle'></span>
</button>
<button class='btn btn-success' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'>
<span class='fas fa-check-circle'></span>
</button>
{% elif part.active %}
<!-- Export menu -->
<div class='btn-group'>
<button id='export-options' title='{% trans "Export actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-download'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li>
<li><a href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li>
</ul>
</div>
{% if roles.part.change %}
<button class='btn btn-primary' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'>
<span class='fas fa-edit'></span>
</button>
{% if part.is_bom_valid == False %}
<button class='btn btn-success' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'>
<span class='fas fa-clipboard-check'></span>
<!-- Action menu -->
<div class='btn-group'>
<button id='bom-actions' title='{% trans "BOM actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-wrench'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='bom-upload'><span class='fas fa-file-upload'></span> {% trans "Upload BOM" %}</a></li>
{% if part.variant_of %}
<li><a href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li>
{% endif %}
{% if not part.is_bom_valid %}
<li><a href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li>
{% endif %}
<li><a href='#' id='bom-item-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Items" %}</a></li>
</ul>
</div>
<button class='btn btn-success' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %}
</button>
{% endif %}
{% endif %}
{% endif %}
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default' id='download-bom' type='button'>
<span class='fas fa-file-download'></span>
</button>
<button title='{% trans "Print BOM Report" %}' class='btn btn-default' id='print-bom-report' type='button'>
<span class='fas fa-file-pdf'></span>
</button>
<div class='filter-list' id='filter-list-bom'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
</div>
@ -67,4 +64,3 @@
<table class='table table-bom table-condensed' data-toolbar="#bom-button-toolbar" id='bom-table'>
</table>
{% endif %}

View File

@ -473,7 +473,11 @@
onPanelLoad("bom", function() {
// Load the BOM table data
loadBomTable($("#bom-table"), {
editable: {{ editing_enabled }},
{% if roles.part.change %}
editable: true,
{% else %}
editable: false,
{% endif %}
bom_url: "{% url 'api-bom-list' %}",
part_url: "{% url 'api-part-list' %}",
parent_id: {{ part.id }} ,
@ -486,11 +490,6 @@
]
);
{% if editing_enabled %}
$("#editing-finished").click(function() {
location.href = "{% url 'part-detail' part.id %}?display=bom";
});
$('#bom-item-delete').click(function() {
// Get a list of the selected BOM items
@ -559,8 +558,6 @@
});
});
{% else %}
$("#validate-bom").click(function() {
launchModalForm(
"{% url 'bom-validate' part.id %}",
@ -570,10 +567,6 @@
);
});
$("#edit-bom").click(function () {
location.href = "{% url 'part-detail' part.id %}?display=bom&edit=1";
});
$("#download-bom").click(function () {
launchModalForm("{% url 'bom-export' part.id %}",
{
@ -584,8 +577,6 @@
);
});
{% endif %}
$("#print-bom-report").click(function() {
printBomReports([{{ part.pk }}]);
});
@ -629,10 +620,9 @@
});
});
// Load the BOM table data in the pricing view
loadBomTable($("#bom-pricing-table"), {
editable: {{ editing_enabled }},
editable: false,
bom_url: "{% url 'api-bom-list' %}",
part_url: "{% url 'api-part-list' %}",
parent_id: {{ part.id }} ,

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import PIL
@ -11,7 +12,8 @@ from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
from part.models import Part, PartCategory
from stock.models import StockItem
from part.models import BomItem, BomItemSubstitute
from stock.models import StockItem, StockLocation
from company.models import Company
from common.models import InvenTreeSetting
@ -273,53 +275,6 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 3)
def test_get_bom_list(self):
""" There should be 4 BomItem objects in the database """
url = reverse('api-bom-list')
response = self.client.get(url, format='json')
self.assertEqual(len(response.data), 5)
def test_get_bom_detail(self):
# Get the detail for a single BomItem
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(int(float(response.data['quantity'])), 25)
# Increase the quantity
data = response.data
data['quantity'] = 57
data['note'] = 'Added a note'
response = self.client.patch(url, data, format='json')
# Check that the quantity was increased and a note added
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(int(float(response.data['quantity'])), 57)
self.assertEqual(response.data['note'], 'Added a note')
def test_add_bom_item(self):
url = reverse('api-bom-list')
data = {
'part': 100,
'sub_part': 4,
'quantity': 777,
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Now try to create a BomItem which points to a non-assembly part (should fail)
data['part'] = 3
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# TODO - Now try to create a BomItem which references itself
data['part'] = 2
data['sub_part'] = 2
response = self.client.post(url, data, format='json')
def test_test_templates(self):
url = reverse('api-part-test-template-list')
@ -926,6 +881,249 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['stock_item_count'], 105)
class BomItemTest(InvenTreeAPITestCase):
"""
Unit tests for the BomItem API
"""
fixtures = [
'category',
'part',
'location',
'stock',
'bom',
'company',
]
roles = [
'part.add',
'part.change',
'part.delete',
]
def setUp(self):
super().setUp()
def test_bom_list(self):
"""
Tests for the BomItem list endpoint
"""
# How many BOM items currently exist in the database?
n = BomItem.objects.count()
url = reverse('api-bom-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), n)
# Now, filter by part
response = self.get(
url,
data={
'part': 100,
},
expected_code=200
)
print("results:", len(response.data))
def test_get_bom_detail(self):
"""
Get the detail view for a single BomItem object
"""
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
response = self.get(url, expected_code=200)
self.assertEqual(int(float(response.data['quantity'])), 25)
# Increase the quantity
data = response.data
data['quantity'] = 57
data['note'] = 'Added a note'
response = self.patch(url, data, expected_code=200)
self.assertEqual(int(float(response.data['quantity'])), 57)
self.assertEqual(response.data['note'], 'Added a note')
def test_add_bom_item(self):
"""
Test that we can create a new BomItem via the API
"""
url = reverse('api-bom-list')
data = {
'part': 100,
'sub_part': 4,
'quantity': 777,
}
self.post(url, data, expected_code=201)
# Now try to create a BomItem which references itself
data['part'] = 100
data['sub_part'] = 100
self.client.post(url, data, expected_code=400)
def test_variants(self):
"""
Tests for BomItem use with variants
"""
stock_url = reverse('api-stock-list')
# BOM item we are interested in
bom_item = BomItem.objects.get(pk=1)
bom_item.allow_variants = True
bom_item.save()
# sub part that the BOM item points to
sub_part = bom_item.sub_part
sub_part.is_template = True
sub_part.save()
# How many stock items are initially available for this part?
response = self.get(
stock_url,
{
'bom_item': bom_item.pk,
},
expected_code=200
)
n_items = len(response.data)
self.assertEqual(n_items, 2)
loc = StockLocation.objects.get(pk=1)
# Now we will create some variant parts and stock
for ii in range(5):
# Create a variant part!
variant = Part.objects.create(
name=f"Variant_{ii}",
description="A variant part",
component=True,
variant_of=sub_part
)
variant.save()
Part.objects.rebuild()
# Create some stock items for this new part
for jj in range(ii):
StockItem.objects.create(
part=variant,
location=loc,
quantity=100
)
# Keep track of running total
n_items += ii
# Now, there should be more stock items available!
response = self.get(
stock_url,
{
'bom_item': bom_item.pk,
},
expected_code=200
)
self.assertEqual(len(response.data), n_items)
# Now, disallow variant parts in the BomItem
bom_item.allow_variants = False
bom_item.save()
# There should now only be 2 stock items available again
response = self.get(
stock_url,
{
'bom_item': bom_item.pk,
},
expected_code=200
)
self.assertEqual(len(response.data), 2)
def test_substitutes(self):
"""
Tests for BomItem substitutes
"""
url = reverse('api-bom-substitute-list')
stock_url = reverse('api-stock-list')
# Initially we have no substitute parts
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 0)
# BOM item we are interested in
bom_item = BomItem.objects.get(pk=1)
# Filter stock items which can be assigned against this stock item
response = self.get(
stock_url,
{
"bom_item": bom_item.pk,
},
expected_code=200
)
n_items = len(response.data)
loc = StockLocation.objects.get(pk=1)
# Let's make some!
for ii in range(5):
sub_part = Part.objects.create(
name=f"Substitute {ii}",
description="A substitute part",
component=True,
is_template=False,
assembly=False
)
# Create a new StockItem for this Part
StockItem.objects.create(
part=sub_part,
quantity=1000,
location=loc,
)
# Now, create an "alternative" for the BOM Item
BomItemSubstitute.objects.create(
bom_item=bom_item,
part=sub_part
)
# We should be able to filter the API list to just return this new part
response = self.get(url, data={'part': sub_part.pk}, expected_code=200)
self.assertEqual(len(response.data), 1)
# We should also have more stock available to allocate against this BOM item!
response = self.get(
stock_url,
{
"bom_item": bom_item.pk,
},
expected_code=200
)
self.assertEqual(len(response.data), n_items + ii + 1)
# There should now be 5 substitute parts available in the database
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 5)
class PartParameterTest(InvenTreeAPITestCase):
"""
Tests for the ParParameter API

View File

@ -1,8 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import transaction
from django.test import TestCase
import django.core.exceptions as django_exceptions
from decimal import Decimal
from .models import Part, BomItem
from .models import Part, BomItem, BomItemSubstitute
class BomItemTest(TestCase):
@ -130,3 +135,67 @@ class BomItemTest(TestCase):
self.bob.get_bom_price_range(1, internal=True),
(Decimal(27.5), Decimal(87.5))
)
def test_substitutes(self):
"""
Tests for BOM item substitutes
"""
# We will make some subtitute parts for the "orphan" part
bom_item = BomItem.objects.get(
part=self.bob,
sub_part=self.orphan
)
# No substitute parts available
self.assertEqual(bom_item.substitutes.count(), 0)
subs = []
for ii in range(5):
# Create a new part
sub_part = Part.objects.create(
name=f"Orphan {ii}",
description="A substitute part for the orphan part",
component=True,
is_template=False,
assembly=False,
)
subs.append(sub_part)
# Link it as a substitute part
BomItemSubstitute.objects.create(
bom_item=bom_item,
part=sub_part
)
# Try to link it again (this should fail as it is a duplicate substitute)
with self.assertRaises(django_exceptions.ValidationError):
with transaction.atomic():
BomItemSubstitute.objects.create(
bom_item=bom_item,
part=sub_part
)
# There should be now 5 substitute parts available
self.assertEqual(bom_item.substitutes.count(), 5)
# Try to create a substitute which points to the same sub-part (should fail)
with self.assertRaises(django_exceptions.ValidationError):
BomItemSubstitute.objects.create(
bom_item=bom_item,
part=self.orphan,
)
# Remove one substitute part
bom_item.substitutes.last().delete()
self.assertEqual(bom_item.substitutes.count(), 4)
for sub in subs:
sub.delete()
# The substitution links should have been automatically removed
self.assertEqual(bom_item.substitutes.count(), 0)

View File

@ -87,16 +87,6 @@ class PartDetailTest(PartViewTestCase):
self.assertEqual(response.context['part'].pk, pk)
self.assertEqual(response.context['category'], part.category)
self.assertFalse(response.context['editing_enabled'])
def test_editable(self):
pk = 1
response = self.client.get(reverse('part-detail', args=(pk,)), {'edit': True})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context['editing_enabled'])
def test_part_detail_from_ipn(self):
"""
Test that we can retrieve a part detail page from part IPN:

View File

@ -404,20 +404,13 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
# Add in some extra context information based on query params
def get_context_data(self, **kwargs):
""" Provide extra context data to template
- If '?editing=True', set 'editing_enabled' context variable
"""
Provide extra context data to template
"""
context = super().get_context_data(**kwargs)
part = self.get_object()
if str2bool(self.request.GET.get('edit', '')):
# Allow BOM editing if the part is active
context['editing_enabled'] = 1 if part.active else 0
else:
context['editing_enabled'] = 0
ctx = part.get_context_data(self.request)
context.update(**ctx)

View File

@ -72,7 +72,9 @@
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group'>
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><
span class='fas fa-qrcode'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% if roles.stock.change %}

View File

@ -143,6 +143,146 @@ function newPartFromBomWizard(e) {
}
/*
* Launch a modal dialog displaying the "substitute parts" for a particular BomItem
*
* If editable, allows substitutes to be added and deleted
*/
function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
// Reload data for the parent table
function reloadParentTable() {
if (options.table) {
options.table.bootstrapTable('refresh');
}
}
function renderSubstituteRow(substitute) {
var pk = substitute.pk;
var part = substitute.part_detail;
var thumb = thumbnailImage(part.thumbnail || part.image);
var buttons = '';
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove substitute part" %}');
// Render a single row
var html = `
<tr id='substitute-row-${pk}' class='substitute-row'>
<td id='part-${pk}'>
<a href='/part/${part.pk}/'>
${thumb} ${part.full_name}
</a>
</td>
<td id='description-${pk}'><em>${part.description}</em></td>
<td>${buttons}</td>
</tr>
`;
return html;
}
// Construct a table to render the rows
var rows = '';
substitutes.forEach(function(sub) {
rows += renderSubstituteRow(sub);
});
var html = `
<table class='table table-striped table-condensed' id='substitute-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Description" %}</th>
<th><!-- Actions --></th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
html += `
<div class='alert alert-success alert-block'>
{% trans "Select and add a new variant item using the input below" %}
</div>
`;
// Add a callback to remove a row from the table
function addRemoveCallback(modal, element) {
$(modal).find(element).click(function() {
var pk = $(this).attr('pk');
var pre = `
<div class='alert alert-block alert-warning'>
{% trans "Are you sure you wish to remove this substitute part link?" %}
</div>
`;
constructForm(`/api/bom/substitute/${pk}/`, {
method: 'DELETE',
title: '{% trans "Remove Substitute Part" %}',
preFormContent: pre,
confirm: true,
onSuccess: function() {
$(modal).find(`#substitute-row-${pk}`).remove();
reloadParentTable();
}
});
});
}
constructForm('{% url "api-bom-substitute-list" %}', {
method: 'POST',
fields: {
bom_item: {
hidden: true,
value: bom_item_id,
},
part: {
required: false,
},
},
preFormContent: html,
cancelText: '{% trans "Close" %}',
submitText: '{% trans "Add Substitute" %}',
title: '{% trans "Edit BOM Item Substitutes" %}',
afterRender: function(fields, opts) {
addRemoveCallback(opts.modal, '.button-row-remove');
},
preventClose: true,
onSuccess: function(response, opts) {
// Clear the form
var field = {
type: 'related field',
};
updateFieldValue('part', null, field, opts);
// Add the new substitute to the table
var row = renderSubstituteRow(response);
$(opts.modal).find('#substitute-table > tbody:last-child').append(row);
// Add a callback to the new button
addRemoveCallback(opts.modal, `#button-row-remove-${response.pk}`);
// Re-enable the "submit" button
$(opts.modal).find('#modal-form-submit').prop('disabled', false);
// Reload the parent BOM table
reloadParentTable();
}
});
}
function loadBomTable(table, options) {
/* Load a BOM table with some configurable options.
*
@ -229,6 +369,14 @@ function loadBomTable(table, options) {
html += makePartIcons(row.sub_part_detail);
if (row.substitutes && row.substitutes.length > 0) {
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitutes Available" %}');
}
if (row.allow_variants) {
html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}');
}
// Display an extra icon if this part is an assembly
if (sub_part.assembly) {
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
@ -300,6 +448,20 @@ function loadBomTable(table, options) {
return renderLink(text, url);
}
});
cols.push({
field: 'substitutes',
title: '{% trans "Substitutes" %}',
searchable: false,
sortable: true,
formatter: function(value, row) {
if (row.substitutes && row.substitutes.length > 0) {
return row.substitutes.length;
} else {
return `-`;
}
}
});
if (show_pricing) {
cols.push({
@ -420,18 +582,17 @@ function loadBomTable(table, options) {
if (row.part == options.parent_id) {
var bValidate = `<button title='{% trans "Validate BOM Item" %}' class='bom-validate-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-check-circle icon-blue'/></button>`;
var bValidate = makeIconButton('fa-check-circle icon-green', 'bom-validate-button', row.pk, '{% trans "Validate BOM Item" %}');
var bValid = `<span title='{% trans "This line has been validated" %}' class='fas fa-check-double icon-green'/>`;
var bEdit = `<button title='{% trans "Edit BOM Item" %}' class='bom-edit-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-edit'></span></button>`;
var bSubs = makeIconButton('fa-exchange-alt icon-blue', 'bom-substitutes-button', row.pk, '{% trans "Edit substitute parts" %}');
var bDelt = `<button title='{% trans "Delete BOM Item" %}' class='bom-delete-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-trash-alt icon-red'></span></button>`;
var bEdit = makeIconButton('fa-edit icon-blue', 'bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}');
var html = `<div class='btn-group' role='group'>`;
var bDelt = makeIconButton('fa-trash-alt icon-red', 'bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}');
html += bEdit;
html += bDelt;
var html = `<div class='btn-group float-right' role='group' style='min-width: 100px;'>`;
if (!row.validated) {
html += bValidate;
@ -439,6 +600,10 @@ function loadBomTable(table, options) {
html += bValid;
}
html += bEdit;
html += bSubs;
html += bDelt;
html += `</div>`;
return html;
@ -490,6 +655,7 @@ function loadBomTable(table, options) {
treeEnable: !options.editable,
rootParentId: parent_id,
idField: 'pk',
uniqueId: 'pk',
parentIdField: 'parentId',
treeShowField: 'sub_part',
showColumns: true,
@ -566,19 +732,27 @@ function loadBomTable(table, options) {
// In editing mode, attached editables to the appropriate table elements
if (options.editable) {
// Callback for "delete" button
table.on('click', '.bom-delete-button', function() {
var pk = $(this).attr('pk');
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Are you sure you want to delete this BOM item?" %}
</div>`;
constructForm(`/api/bom/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete BOM Item" %}',
preFormContent: html,
onSuccess: function() {
reloadBomTable(table);
}
});
});
// Callback for "edit" button
table.on('click', '.bom-edit-button', function() {
var pk = $(this).attr('pk');
@ -595,6 +769,7 @@ function loadBomTable(table, options) {
});
});
// Callback for "validate" button
table.on('click', '.bom-validate-button', function() {
var pk = $(this).attr('pk');
@ -613,5 +788,21 @@ function loadBomTable(table, options) {
}
);
});
// Callback for "substitutes" button
table.on('click', '.bom-substitutes-button', function() {
var pk = $(this).attr('pk');
var row = table.bootstrapTable('getRowByUniqueId', pk);
var subs = row.substitutes || [];
bomSubstitutesDialog(
pk,
subs,
{
table: table,
}
);
});
}
}

View File

@ -208,15 +208,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
var pk = $(this).attr('pk');
launchModalForm(
`/build/${buildId}/unallocate/`,
{
success: reloadTable,
data: {
output: pk,
}
}
);
unallocateStock(buildId, {
output: pk,
table: table,
});
});
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
@ -236,6 +231,49 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
}
/*
* Unallocate stock against a particular build order
*
* Options:
* - output: pk value for a stock item "build output"
* - bom_item: pk value for a particular BOMItem (build item)
*/
function unallocateStock(build_id, options={}) {
var url = `/api/build/${build_id}/unallocate/`;
var html = `
<div class='alert alert-block alert-warning'>
{% trans "Are you sure you wish to unallocate stock items from this build?" %}
</dvi>
`;
constructForm(url, {
method: 'POST',
confirm: true,
preFormContent: html,
fields: {
output: {
hidden: true,
value: options.output,
},
bom_item: {
hidden: true,
value: options.bom_item,
},
},
title: '{% trans "Unallocate Stock Items" %}',
onSuccess: function(response, opts) {
if (options.table) {
// Reload the parent table
$(options.table).bootstrapTable('refresh');
}
}
});
}
function loadBuildOrderAllocationTable(table, options={}) {
/**
* Load a table showing all the BuildOrder allocations for a given part
@ -348,6 +386,17 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
table = `#allocation-table-${outputId}`;
}
// Filters
var filters = loadTableFilters('builditems');
var params = options.params || {};
for (var key in params) {
filters[key] = params[key];
}
setupFilterList('builditems', $(table), options.filterTarget || null);
// If an "output" is specified, then only "trackable" parts are allocated
// Otherwise, only "untrackable" parts are allowed
var trackable = ! !output;
@ -458,17 +507,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Callback for 'unallocate' button
$(table).find('.button-unallocate').click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/build/${buildId}/unallocate/`,
{
success: reloadTable,
data: {
output: outputId,
part: pk,
}
}
);
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = $(table).bootstrapTable('getData')[idx];
unallocateStock(buildId, {
bom_item: row.pk,
output: outputId == 'untracked' ? null : outputId,
table: table,
});
});
}
@ -726,6 +774,14 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
html += makePartIcons(row.sub_part_detail);
if (row.substitutes && row.substitutes.length > 0) {
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitute parts available" %}');
}
if (row.allow_variants) {
html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}');
}
return html;
}
},
@ -1021,12 +1077,12 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
filters: {
bom_item: bom_item.pk,
in_stock: true,
part_detail: false,
part_detail: true,
location_detail: true,
},
model: 'stockitem',
required: true,
render_part_detail: false,
render_part_detail: true,
render_location_detail: true,
auto_fill: true,
adjustFilters: function(filters) {

View File

@ -283,18 +283,21 @@ function setupFilterList(tableKey, table, target) {
element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-default filter-tag'><span class='fas fa-redo-alt'></span></button>`);
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`);
// If there are available filters, add them in!
if (filters.length > 0) {
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`);
if (Object.keys(filters).length > 0) {
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-default filter-tag'><span class='fas fa-trash-alt'></span></button>`);
}
if (Object.keys(filters).length > 0) {
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-default filter-tag'><span class='fas fa-trash-alt'></span></button>`);
}
for (var key in filters) {
var value = getFilterOptionValue(tableKey, key, filters[key]);
var title = getFilterTitle(tableKey, key);
var description = getFilterDescription(tableKey, key);
for (var key in filters) {
var value = getFilterOptionValue(tableKey, key, filters[key]);
var title = getFilterTitle(tableKey, key);
var description = getFilterDescription(tableKey, key);
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
}
}
// Callback for reloading the table

View File

@ -52,8 +52,6 @@ function renderStockItem(name, data, parameters, options) {
if (data.part_detail) {
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
}
var html = '';
var render_part_detail = true;
@ -61,23 +59,10 @@ function renderStockItem(name, data, parameters, options) {
render_part_detail = parameters['render_part_detail'];
}
var part_detail = '';
if (render_part_detail) {
html += `<img src='${image}' class='select2-thumbnail'>`;
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
}
html += '<span>';
if (data.serial && data.quantity == 1) {
html += `{% trans "Serial Number" %}: ${data.serial}`;
} else {
html += `{% trans "Quantity" %}: ${data.quantity}`;
}
html += '</span>';
if (render_part_detail && data.part_detail.description) {
html += `<p><small>${data.part_detail.description}</small></p>`;
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
}
var render_stock_id = true;
@ -86,8 +71,10 @@ function renderStockItem(name, data, parameters, options) {
render_stock_id = parameters['render_stock_id'];
}
var stock_id = '';
if (render_stock_id) {
html += `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
stock_id = `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
}
var render_location_detail = false;
@ -96,10 +83,28 @@ function renderStockItem(name, data, parameters, options) {
render_location_detail = parameters['render_location_detail'];
}
var location_detail = '';
if (render_location_detail && data.location_detail) {
html += `<span> - ${data.location_detail.name}</span>`;
location_detail = ` - (<em>${data.location_detail.name}</em>)`;
}
var stock_detail = '';
if (data.serial && data.quantity == 1) {
stock_detail = `{% trans "Serial Number" %}: ${data.serial}`;
} else if (data.quantity == 0) {
stock_detail = `<span class='label-form label-red'>{% trans "No Stock"% }</span>`;
} else {
stock_detail = `{% trans "Quantity" %}: ${data.quantity}`;
}
var html = `
<span>
${part_detail}${stock_detail}${location_detail}${stock_id}
</span>
`;
return html;
}
@ -159,21 +164,25 @@ function renderPart(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`;
}
var stock = '';
var extra = '';
// Display available part quantity
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
if (data.in_stock == 0) {
stock = `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
extra += `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
} else {
stock = `<span class='label-form label-green'>{% trans "In Stock" %}: ${data.in_stock}</span>`;
extra += `<span class='label-form label-green'>{% trans "Stock" %}: ${data.in_stock}</span>`;
}
}
if (!data.active) {
extra += `<span class='label-form label-red'>{% trans "Inactive" %}</span>`;
}
html += `
<span class='float-right'>
<small>
${stock}
${extra}
{% trans "Part ID" %}: ${data.pk}
</small>
</span>`;

View File

@ -592,7 +592,7 @@ function loadPartParameterTable(table, url, options) {
filters[key] = params[key];
}
// setupFilterLsit("#part-parameters", $(table));
// setupFilterList("#part-parameters", $(table));
$(table).inventreeTable({
url: url,

View File

@ -81,6 +81,7 @@ class RuleSet(models.Model):
'part': [
'part_part',
'part_bomitem',
'part_bomitemsubstitute',
'part_partattachment',
'part_partsellpricebreak',
'part_partinternalpricebreak',
@ -110,6 +111,7 @@ class RuleSet(models.Model):
'part_part',
'part_partcategory',
'part_bomitem',
'part_bomitemsubstitute',
'build_build',
'build_builditem',
'build_buildorderattachment',