mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2150 from SchrodingersGat/bom-substitutes
Bom substitutes
This commit is contained in:
commit
22572c6f35
InvenTree
InvenTree/static/css
build
part
api.py
migrations
models.pyserializers.pytemplates/part
test_api.pytest_bom_item.pytest_views.pyviews.pystock/templates/stock
templates/js/translated
users
@ -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 {
|
||||||
|
@ -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'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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() {
|
||||||
|
@ -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 %}
|
|
@ -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)
|
||||||
|
|
||||||
|
@ -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'])
|
|
||||||
|
@ -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'),
|
||||||
|
@ -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.
|
||||||
|
@ -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'),
|
||||||
|
22
InvenTree/part/migrations/0072_bomitemsubstitute.py
Normal file
22
InvenTree/part/migrations/0072_bomitemsubstitute.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
21
InvenTree/part/migrations/0073_auto_20211013_1048.py
Normal file
21
InvenTree/part/migrations/0073_auto_20211013_1048.py
Normal 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')},
|
||||||
|
),
|
||||||
|
]
|
@ -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.) """
|
||||||
|
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
@ -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 %}
|
|
@ -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 }} ,
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
|
@ -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>`;
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
Loading…
Reference in New Issue
Block a user