Merge pull request 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%; -webkit-opacity: 10%;
} }
.table-condensed {
font-size: 90%;
}
/* grid display for part images */ /* grid display for part images */
.table-img-grid tr { .table-img-grid tr {

View File

@ -21,7 +21,7 @@ from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem, BuildOrderAttachment from .models import Build, BuildItem, BuildOrderAttachment
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
from .serializers import BuildAllocationSerializer from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
class BuildFilter(rest_filters.FilterSet): class BuildFilter(rest_filters.FilterSet):
@ -184,6 +184,42 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
serializer_class = BuildSerializer 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): class BuildAllocate(generics.CreateAPIView):
""" """
API endpoint to allocate stock items to a build order API endpoint to allocate stock items to a build order
@ -349,6 +385,7 @@ build_api_urls = [
# Build Detail # Build Detail
url(r'^(?P<pk>\d+)/', include([ url(r'^(?P<pk>\d+)/', include([
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), 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'), 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): class CompleteBuildForm(HelperForm):
""" """
Form for marking a build as complete Form for marking a build as complete

View File

@ -587,9 +587,13 @@ class Build(MPTTModel):
self.save() self.save()
@transaction.atomic @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( allocations = BuildItem.objects.filter(
@ -597,34 +601,8 @@ class Build(MPTTModel):
install_into=output install_into=output
) )
if part: if bom_item:
allocations = allocations.filter(stock_item__part=part) allocations = allocations.filter(bom_item=bom_item)
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)
allocations.delete() allocations.delete()
@ -720,7 +698,7 @@ class Build(MPTTModel):
raise ValidationError(_("Build output does not match Build Order")) raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output # Unallocate all build items against the output
self.unallocateOutput(output) self.unallocateStock(output=output)
# Remove the build output from the database # Remove the build output from the database
output.delete() output.delete()
@ -1153,16 +1131,12 @@ class BuildItem(models.Model):
i) The sub_part points to the same part as the referenced StockItem 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 ii) The BomItem allows variants and the part referenced by the StockItem
is a variant of the sub_part referenced by the BomItem 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: 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) bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item)
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
# If the existing BomItem is *not* valid, try to find a match # If the existing BomItem is *not* valid, try to find a match
if not bom_item_valid: 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): class BuildAllocationItemSerializer(serializers.Serializer):
""" """
A serializer for allocating a single stock item against a build order 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" %}'> <button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span> <span class='fas fa-sign-in-alt'></span>
</button> </button>
<div class='filter-list' id='filter-list-build-items'> <div class='filter-list' id='filter-list-builditems'>
<!-- Empty div for table filters--> <!-- Empty div for table filters-->
</div> </div>
</div> </div>
@ -462,12 +462,9 @@ $("#btn-auto-allocate").on('click', function() {
}); });
$('#btn-unallocate').on('click', function() { $('#btn-unallocate').on('click', function() {
launchModalForm( unallocateStock({{ build.id }}, {
"{% url 'build-unallocate' build.id %}", table: '#allocation-table-untracked',
{ });
success: reloadTable,
}
);
}); });
$('#allocate-selected-items').click(function() { $('#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.assertEqual(len(unallocated), 1)
self.build.unallocateUntracked() self.build.unallocateStock()
unallocated = self.build.unallocatedParts(None) unallocated = self.build.unallocatedParts(None)

View File

@ -323,22 +323,3 @@ class TestBuildViews(TestCase):
b = Build.objects.get(pk=1) b = Build.objects.get(pk=1)
self.assertEqual(b.status, 30) # Build status is now CANCELLED 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'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), 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'^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'^complete/', views.BuildComplete.as_view(), name='build-complete'),
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), 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.views.generic import DetailView, ListView
from django.forms import HiddenInput from django.forms import HiddenInput
from part.models import Part
from .models import Build from .models import Build
from . import forms from . import forms
from stock.models import StockLocation, StockItem from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin 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 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): class BuildComplete(AjaxUpdateView):
""" """
View to mark the build as complete. 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 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 PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
@ -1078,11 +1079,23 @@ class BomList(generics.ListCreateAPIView):
queryset = self.filter_queryset(self.get_queryset()) 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 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) return JsonResponse(data, safe=False)
else: else:
return Response(data) return Response(data)
@ -1102,7 +1115,7 @@ class BomList(generics.ListCreateAPIView):
try: try:
# Include or exclude pricing information in the serialized data # 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: except AttributeError:
pass pass
@ -1147,13 +1160,19 @@ class BomList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass pass
include_pricing = str2bool(params.get('include_pricing', True)) if self.include_pricing():
if include_pricing:
queryset = self.annotate_pricing(queryset) queryset = self.annotate_pricing(queryset)
return 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): def annotate_pricing(self, queryset):
""" """
Add part pricing information to the queryset Add part pricing information to the queryset
@ -1262,6 +1281,35 @@ class BomItemValidate(generics.UpdateAPIView):
return Response(serializer.data) 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 = [ part_api_urls = [
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'), url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
@ -1314,6 +1362,16 @@ part_api_urls = [
] ]
bom_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 # BOM Item Detail
url(r'^(?P<pk>\d+)/', include([ url(r'^(?P<pk>\d+)/', include([
url(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'), 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(): def get_api_url():
return reverse('api-bom-list') 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): def get_stock_filter(self):
""" """
Return a queryset filter for selecting StockItems which match this BomItem 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 - If allow_variants is True, allow all part variants
""" """
# Target part return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
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)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -2613,6 +2639,66 @@ class BomItem(models.Model):
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax) 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): class PartRelated(models.Model):
""" Store and handle related parts (eg. mating connector, crimps, etc.) """ """ 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 InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory, from .models import (BomItem, BomItemSubstitute,
Part, PartAttachment, PartCategory,
PartParameter, PartParameterTemplate, PartSellPriceBreak, PartParameter, PartParameterTemplate, PartSellPriceBreak,
PartStar, PartTestTemplate, PartCategoryParameterTemplate, PartStar, PartTestTemplate, PartCategoryParameterTemplate,
PartInternalPriceBreak) 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): class BomItemSerializer(InvenTreeModelSerializer):
""" Serializer for BomItem object """ """
Serializer for BomItem object
"""
price_range = serializers.CharField(read_only=True) price_range = serializers.CharField(read_only=True)
@ -397,6 +417,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True)) 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) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True)) sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True))
@ -515,6 +537,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
'reference', 'reference',
'sub_part', 'sub_part',
'sub_part_detail', 'sub_part_detail',
'substitutes',
'price_range', 'price_range',
'validated', 'validated',
] ]

View File

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

View File

@ -473,7 +473,11 @@
onPanelLoad("bom", function() { onPanelLoad("bom", function() {
// Load the BOM table data // Load the BOM table data
loadBomTable($("#bom-table"), { loadBomTable($("#bom-table"), {
editable: {{ editing_enabled }}, {% if roles.part.change %}
editable: true,
{% else %}
editable: false,
{% endif %}
bom_url: "{% url 'api-bom-list' %}", bom_url: "{% url 'api-bom-list' %}",
part_url: "{% url 'api-part-list' %}", part_url: "{% url 'api-part-list' %}",
parent_id: {{ part.id }} , 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() { $('#bom-item-delete').click(function() {
// Get a list of the selected BOM items // Get a list of the selected BOM items
@ -559,8 +558,6 @@
}); });
}); });
{% else %}
$("#validate-bom").click(function() { $("#validate-bom").click(function() {
launchModalForm( launchModalForm(
"{% url 'bom-validate' part.id %}", "{% 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 () { $("#download-bom").click(function () {
launchModalForm("{% url 'bom-export' part.id %}", launchModalForm("{% url 'bom-export' part.id %}",
{ {
@ -584,8 +577,6 @@
); );
}); });
{% endif %}
$("#print-bom-report").click(function() { $("#print-bom-report").click(function() {
printBomReports([{{ part.pk }}]); printBomReports([{{ part.pk }}]);
}); });
@ -629,10 +620,9 @@
}); });
}); });
// Load the BOM table data in the pricing view // Load the BOM table data in the pricing view
loadBomTable($("#bom-pricing-table"), { loadBomTable($("#bom-pricing-table"), {
editable: {{ editing_enabled }}, editable: false,
bom_url: "{% url 'api-bom-list' %}", bom_url: "{% url 'api-bom-list' %}",
part_url: "{% url 'api-part-list' %}", part_url: "{% url 'api-part-list' %}",
parent_id: {{ part.id }} , parent_id: {{ part.id }} ,

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
import PIL import PIL
@ -11,7 +12,8 @@ from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
from part.models import Part, PartCategory 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 company.models import Company
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
@ -273,53 +275,6 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 3) 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): def test_test_templates(self):
url = reverse('api-part-test-template-list') url = reverse('api-part-test-template-list')
@ -926,6 +881,249 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['stock_item_count'], 105) 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): class PartParameterTest(InvenTreeAPITestCase):
""" """
Tests for the ParParameter API 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 from django.test import TestCase
import django.core.exceptions as django_exceptions import django.core.exceptions as django_exceptions
from decimal import Decimal from decimal import Decimal
from .models import Part, BomItem from .models import Part, BomItem, BomItemSubstitute
class BomItemTest(TestCase): class BomItemTest(TestCase):
@ -130,3 +135,67 @@ class BomItemTest(TestCase):
self.bob.get_bom_price_range(1, internal=True), self.bob.get_bom_price_range(1, internal=True),
(Decimal(27.5), Decimal(87.5)) (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['part'].pk, pk)
self.assertEqual(response.context['category'], part.category) 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): def test_part_detail_from_ipn(self):
""" """
Test that we can retrieve a part detail page from part IPN: 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 # Add in some extra context information based on query params
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
""" Provide extra context data to template """
Provide extra context data to template
- If '?editing=True', set 'editing_enabled' context variable
""" """
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
part = self.get_object() 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) ctx = part.get_context_data(self.request)
context.update(**ctx) context.update(**ctx)

View File

@ -72,7 +72,9 @@
{% if barcodes %} {% if barcodes %}
<!-- Barcode actions menu --> <!-- Barcode actions menu -->
<div class='btn-group'> <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'> <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> <li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% if roles.stock.change %} {% 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) { function loadBomTable(table, options) {
/* Load a BOM table with some configurable options. /* Load a BOM table with some configurable options.
* *
@ -229,6 +369,14 @@ function loadBomTable(table, options) {
html += makePartIcons(row.sub_part_detail); 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 // Display an extra icon if this part is an assembly
if (sub_part.assembly) { if (sub_part.assembly) {
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`; var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
@ -301,6 +449,20 @@ function loadBomTable(table, options) {
} }
}); });
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) { if (show_pricing) {
cols.push({ cols.push({
field: 'purchase_price_range', field: 'purchase_price_range',
@ -420,18 +582,17 @@ function loadBomTable(table, options) {
if (row.part == options.parent_id) { 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 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; var html = `<div class='btn-group float-right' role='group' style='min-width: 100px;'>`;
html += bDelt;
if (!row.validated) { if (!row.validated) {
html += bValidate; html += bValidate;
@ -439,6 +600,10 @@ function loadBomTable(table, options) {
html += bValid; html += bValid;
} }
html += bEdit;
html += bSubs;
html += bDelt;
html += `</div>`; html += `</div>`;
return html; return html;
@ -490,6 +655,7 @@ function loadBomTable(table, options) {
treeEnable: !options.editable, treeEnable: !options.editable,
rootParentId: parent_id, rootParentId: parent_id,
idField: 'pk', idField: 'pk',
uniqueId: 'pk',
parentIdField: 'parentId', parentIdField: 'parentId',
treeShowField: 'sub_part', treeShowField: 'sub_part',
showColumns: true, showColumns: true,
@ -566,19 +732,27 @@ function loadBomTable(table, options) {
// In editing mode, attached editables to the appropriate table elements // In editing mode, attached editables to the appropriate table elements
if (options.editable) { if (options.editable) {
// Callback for "delete" button
table.on('click', '.bom-delete-button', function() { table.on('click', '.bom-delete-button', function() {
var pk = $(this).attr('pk'); 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}/`, { constructForm(`/api/bom/${pk}/`, {
method: 'DELETE', method: 'DELETE',
title: '{% trans "Delete BOM Item" %}', title: '{% trans "Delete BOM Item" %}',
preFormContent: html,
onSuccess: function() { onSuccess: function() {
reloadBomTable(table); reloadBomTable(table);
} }
}); });
}); });
// Callback for "edit" button
table.on('click', '.bom-edit-button', function() { table.on('click', '.bom-edit-button', function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
@ -595,6 +769,7 @@ function loadBomTable(table, options) {
}); });
}); });
// Callback for "validate" button
table.on('click', '.bom-validate-button', function() { table.on('click', '.bom-validate-button', function() {
var pk = $(this).attr('pk'); 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'); var pk = $(this).attr('pk');
launchModalForm( unallocateStock(buildId, {
`/build/${buildId}/unallocate/`, output: pk,
{ table: table,
success: reloadTable, });
data: {
output: pk,
}
}
);
}); });
$(panel).find(`#button-output-delete-${outputId}`).click(function() { $(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={}) { function loadBuildOrderAllocationTable(table, options={}) {
/** /**
* Load a table showing all the BuildOrder allocations for a given part * 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}`; 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 // If an "output" is specified, then only "trackable" parts are allocated
// Otherwise, only "untrackable" parts are allowed // Otherwise, only "untrackable" parts are allowed
var trackable = ! !output; var trackable = ! !output;
@ -458,17 +507,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Callback for 'unallocate' button // Callback for 'unallocate' button
$(table).find('.button-unallocate').click(function() { $(table).find('.button-unallocate').click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/build/${buildId}/unallocate/`, // Extract row data from the table
{ var idx = $(this).closest('tr').attr('data-index');
success: reloadTable, var row = $(table).bootstrapTable('getData')[idx];
data: {
output: outputId, unallocateStock(buildId, {
part: pk, 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); 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; return html;
} }
}, },
@ -1021,12 +1077,12 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
filters: { filters: {
bom_item: bom_item.pk, bom_item: bom_item.pk,
in_stock: true, in_stock: true,
part_detail: false, part_detail: true,
location_detail: true, location_detail: true,
}, },
model: 'stockitem', model: 'stockitem',
required: true, required: true,
render_part_detail: false, render_part_detail: true,
render_location_detail: true, render_location_detail: true,
auto_fill: true, auto_fill: true,
adjustFilters: function(filters) { 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='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) { 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>`); 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) { for (var key in filters) {
var value = getFilterOptionValue(tableKey, key, filters[key]); var value = getFilterOptionValue(tableKey, key, filters[key]);
var title = getFilterTitle(tableKey, key); var title = getFilterTitle(tableKey, key);
var description = getFilterDescription(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 // Callback for reloading the table

View File

@ -53,31 +53,16 @@ function renderStockItem(name, data, parameters, options) {
image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
} }
var html = '';
var render_part_detail = true; var render_part_detail = true;
if ('render_part_detail' in parameters) { if ('render_part_detail' in parameters) {
render_part_detail = parameters['render_part_detail']; render_part_detail = parameters['render_part_detail'];
} }
var part_detail = '';
if (render_part_detail) { if (render_part_detail) {
html += `<img src='${image}' class='select2-thumbnail'>`; part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
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>`;
} }
var render_stock_id = true; var render_stock_id = true;
@ -86,8 +71,10 @@ function renderStockItem(name, data, parameters, options) {
render_stock_id = parameters['render_stock_id']; render_stock_id = parameters['render_stock_id'];
} }
var stock_id = '';
if (render_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; var render_location_detail = false;
@ -96,10 +83,28 @@ function renderStockItem(name, data, parameters, options) {
render_location_detail = parameters['render_location_detail']; render_location_detail = parameters['render_location_detail'];
} }
var location_detail = '';
if (render_location_detail && data.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; return html;
} }
@ -159,21 +164,25 @@ function renderPart(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`; html += ` - <i>${data.description}</i>`;
} }
var stock = ''; var extra = '';
// Display available part quantity // Display available part quantity
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) { if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
if (data.in_stock == 0) { 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 { } 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 += ` html += `
<span class='float-right'> <span class='float-right'>
<small> <small>
${stock} ${extra}
{% trans "Part ID" %}: ${data.pk} {% trans "Part ID" %}: ${data.pk}
</small> </small>
</span>`; </span>`;

View File

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

View File

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