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
@ -455,6 +455,10 @@
|
||||
-webkit-opacity: 10%;
|
||||
}
|
||||
|
||||
.table-condensed {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
/* grid display for part images */
|
||||
|
||||
.table-img-grid tr {
|
||||
|
@ -21,7 +21,7 @@ from InvenTree.status_codes import BuildStatus
|
||||
|
||||
from .models import Build, BuildItem, BuildOrderAttachment
|
||||
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
||||
from .serializers import BuildAllocationSerializer
|
||||
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
|
||||
|
||||
|
||||
class BuildFilter(rest_filters.FilterSet):
|
||||
@ -184,6 +184,42 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
|
||||
serializer_class = BuildSerializer
|
||||
|
||||
|
||||
class BuildUnallocate(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for unallocating stock items from a build order
|
||||
|
||||
- The BuildOrder object is specified by the URL
|
||||
- "output" (StockItem) can optionally be specified
|
||||
- "bom_item" can optionally be specified
|
||||
"""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = BuildUnallocationSerializer
|
||||
|
||||
def get_build(self):
|
||||
"""
|
||||
Returns the BuildOrder associated with this API endpoint
|
||||
"""
|
||||
|
||||
pk = self.kwargs.get('pk', None)
|
||||
|
||||
try:
|
||||
build = Build.objects.get(pk=pk)
|
||||
except (ValueError, Build.DoesNotExist):
|
||||
raise ValidationError(_("Matching build order does not exist"))
|
||||
|
||||
return build
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['build'] = self.get_build()
|
||||
ctx['request'] = self.request
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildAllocate(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocate stock items to a build order
|
||||
@ -349,6 +385,7 @@ build_api_urls = [
|
||||
# Build Detail
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||
])),
|
||||
|
||||
|
@ -137,32 +137,6 @@ class BuildOutputDeleteForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class UnallocateBuildForm(HelperForm):
|
||||
"""
|
||||
Form for auto-de-allocation of stock from a build
|
||||
"""
|
||||
|
||||
confirm = forms.BooleanField(required=False, label=_('Confirm'), help_text=_('Confirm unallocation of stock'))
|
||||
|
||||
output_id = forms.IntegerField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
|
||||
part_id = forms.IntegerField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Build
|
||||
fields = [
|
||||
'confirm',
|
||||
'output_id',
|
||||
'part_id',
|
||||
]
|
||||
|
||||
|
||||
class CompleteBuildForm(HelperForm):
|
||||
"""
|
||||
Form for marking a build as complete
|
||||
|
@ -587,9 +587,13 @@ class Build(MPTTModel):
|
||||
self.save()
|
||||
|
||||
@transaction.atomic
|
||||
def unallocateOutput(self, output, part=None):
|
||||
def unallocateStock(self, bom_item=None, output=None):
|
||||
"""
|
||||
Unallocate all stock which are allocated against the provided "output" (StockItem)
|
||||
Unallocate stock from this Build
|
||||
|
||||
arguments:
|
||||
- bom_item: Specify a particular BomItem to unallocate stock against
|
||||
- output: Specify a particular StockItem (output) to unallocate stock against
|
||||
"""
|
||||
|
||||
allocations = BuildItem.objects.filter(
|
||||
@ -597,34 +601,8 @@ class Build(MPTTModel):
|
||||
install_into=output
|
||||
)
|
||||
|
||||
if part:
|
||||
allocations = allocations.filter(stock_item__part=part)
|
||||
|
||||
allocations.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def unallocateUntracked(self, part=None):
|
||||
"""
|
||||
Unallocate all "untracked" stock
|
||||
"""
|
||||
|
||||
allocations = BuildItem.objects.filter(
|
||||
build=self,
|
||||
install_into=None
|
||||
)
|
||||
|
||||
if part:
|
||||
allocations = allocations.filter(stock_item__part=part)
|
||||
|
||||
allocations.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def unallocateAll(self):
|
||||
"""
|
||||
Deletes all stock allocations for this build.
|
||||
"""
|
||||
|
||||
allocations = BuildItem.objects.filter(build=self)
|
||||
if bom_item:
|
||||
allocations = allocations.filter(bom_item=bom_item)
|
||||
|
||||
allocations.delete()
|
||||
|
||||
@ -720,7 +698,7 @@ class Build(MPTTModel):
|
||||
raise ValidationError(_("Build output does not match Build Order"))
|
||||
|
||||
# Unallocate all build items against the output
|
||||
self.unallocateOutput(output)
|
||||
self.unallocateStock(output=output)
|
||||
|
||||
# Remove the build output from the database
|
||||
output.delete()
|
||||
@ -1153,16 +1131,12 @@ class BuildItem(models.Model):
|
||||
i) The sub_part points to the same part as the referenced StockItem
|
||||
ii) The BomItem allows variants and the part referenced by the StockItem
|
||||
is a variant of the sub_part referenced by the BomItem
|
||||
iii) The Part referenced by the StockItem is a valid substitute for the BomItem
|
||||
"""
|
||||
|
||||
if self.build and self.build.part == self.bom_item.part:
|
||||
|
||||
# Check that the sub_part points to the stock_item (either directly or via a variant)
|
||||
if self.bom_item.sub_part == self.stock_item.part:
|
||||
bom_item_valid = True
|
||||
|
||||
elif self.bom_item.allow_variants and self.stock_item.part in self.bom_item.sub_part.get_descendants(include_self=False):
|
||||
bom_item_valid = True
|
||||
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item)
|
||||
|
||||
# If the existing BomItem is *not* valid, try to find a match
|
||||
if not bom_item_valid:
|
||||
|
@ -120,6 +120,61 @@ class BuildSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class BuildUnallocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for unallocating stock from a BuildOrder
|
||||
|
||||
Allocated stock can be unallocated with a number of filters:
|
||||
|
||||
- output: Filter against a particular build output (blank = untracked stock)
|
||||
- bom_item: Filter against a particular BOM line item
|
||||
|
||||
"""
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BomItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('BOM Item'),
|
||||
)
|
||||
|
||||
output = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.filter(
|
||||
is_building=True,
|
||||
),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_("Build output"),
|
||||
)
|
||||
|
||||
def validate_output(self, stock_item):
|
||||
|
||||
# Stock item must point to the same build order!
|
||||
build = self.context['build']
|
||||
|
||||
if stock_item and stock_item.build != build:
|
||||
raise ValidationError(_("Build output must point to the same build"))
|
||||
|
||||
return stock_item
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
'Save' the serializer data.
|
||||
This performs the actual unallocation against the build order
|
||||
"""
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
build.unallocateStock(
|
||||
bom_item=data['bom_item'],
|
||||
output=data['output']
|
||||
)
|
||||
|
||||
|
||||
class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
A serializer for allocating a single stock item against a build order
|
||||
|
@ -197,7 +197,7 @@
|
||||
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span>
|
||||
</button>
|
||||
<div class='filter-list' id='filter-list-build-items'>
|
||||
<div class='filter-list' id='filter-list-builditems'>
|
||||
<!-- Empty div for table filters-->
|
||||
</div>
|
||||
</div>
|
||||
@ -462,12 +462,9 @@ $("#btn-auto-allocate").on('click', function() {
|
||||
});
|
||||
|
||||
$('#btn-unallocate').on('click', function() {
|
||||
launchModalForm(
|
||||
"{% url 'build-unallocate' build.id %}",
|
||||
{
|
||||
success: reloadTable,
|
||||
}
|
||||
);
|
||||
unallocateStock({{ build.id }}, {
|
||||
table: '#allocation-table-untracked',
|
||||
});
|
||||
});
|
||||
|
||||
$('#allocate-selected-items').click(function() {
|
||||
|
@ -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.build.unallocateUntracked()
|
||||
self.build.unallocateStock()
|
||||
|
||||
unallocated = self.build.unallocatedParts(None)
|
||||
|
||||
|
@ -323,22 +323,3 @@ class TestBuildViews(TestCase):
|
||||
|
||||
b = Build.objects.get(pk=1)
|
||||
self.assertEqual(b.status, 30) # Build status is now CANCELLED
|
||||
|
||||
def test_build_unallocate(self):
|
||||
""" Test the build unallocation view (ajax form) """
|
||||
|
||||
url = reverse('build-unallocate', args=(1,))
|
||||
|
||||
# Test without confirmation
|
||||
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertFalse(data['form_valid'])
|
||||
|
||||
# Test with confirmation
|
||||
response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertTrue(data['form_valid'])
|
||||
|
@ -12,7 +12,6 @@ build_detail_urls = [
|
||||
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
||||
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
||||
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
|
||||
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
|
||||
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
|
||||
|
||||
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||
|
@ -10,14 +10,13 @@ from django.core.exceptions import ValidationError
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.forms import HiddenInput
|
||||
|
||||
from part.models import Part
|
||||
from .models import Build
|
||||
from . import forms
|
||||
from stock.models import StockLocation, StockItem
|
||||
|
||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.helpers import str2bool, extract_serial_numbers, isNull
|
||||
from InvenTree.helpers import str2bool, extract_serial_numbers
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
|
||||
|
||||
@ -246,88 +245,6 @@ class BuildOutputDelete(AjaxUpdateView):
|
||||
}
|
||||
|
||||
|
||||
class BuildUnallocate(AjaxUpdateView):
|
||||
""" View to un-allocate all parts from a build.
|
||||
|
||||
Provides a simple confirmation dialog with a BooleanField checkbox.
|
||||
"""
|
||||
|
||||
model = Build
|
||||
form_class = forms.UnallocateBuildForm
|
||||
ajax_form_title = _("Unallocate Stock")
|
||||
ajax_template_name = "build/unallocate.html"
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super().get_initial()
|
||||
|
||||
# Pointing to a particular build output?
|
||||
output = self.get_param('output')
|
||||
|
||||
if output:
|
||||
initials['output_id'] = output
|
||||
|
||||
# Pointing to a particular part?
|
||||
part = self.get_param('part')
|
||||
|
||||
if part:
|
||||
initials['part_id'] = part
|
||||
|
||||
return initials
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
build = self.get_object()
|
||||
form = self.get_form()
|
||||
|
||||
confirm = request.POST.get('confirm', False)
|
||||
|
||||
output_id = request.POST.get('output_id', None)
|
||||
|
||||
if output_id:
|
||||
|
||||
# If a "null" output is provided, we are trying to unallocate "untracked" stock
|
||||
if isNull(output_id):
|
||||
output = None
|
||||
else:
|
||||
try:
|
||||
output = StockItem.objects.get(pk=output_id)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
output = None
|
||||
|
||||
part_id = request.POST.get('part_id', None)
|
||||
|
||||
try:
|
||||
part = Part.objects.get(pk=part_id)
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
part = None
|
||||
|
||||
valid = False
|
||||
|
||||
if confirm is False:
|
||||
form.add_error('confirm', _('Confirm unallocation of build stock'))
|
||||
form.add_error(None, _('Check the confirmation box'))
|
||||
else:
|
||||
|
||||
valid = True
|
||||
|
||||
# Unallocate the entire build
|
||||
if not output_id:
|
||||
build.unallocateAll()
|
||||
# Unallocate a single output
|
||||
elif output:
|
||||
build.unallocateOutput(output, part=part)
|
||||
# Unallocate "untracked" parts
|
||||
else:
|
||||
build.unallocateUntracked(part=part)
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
|
||||
return self.renderJsonResponse(request, form, data)
|
||||
|
||||
|
||||
class BuildComplete(AjaxUpdateView):
|
||||
"""
|
||||
View to mark the build as complete.
|
||||
|
@ -27,7 +27,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .models import Part, PartCategory, BomItem
|
||||
from .models import Part, PartCategory
|
||||
from .models import BomItem, BomItemSubstitute
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
from .models import PartAttachment, PartTestTemplate
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
@ -1078,11 +1079,23 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
else:
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
|
||||
data = serializer.data
|
||||
|
||||
if request.is_ajax():
|
||||
"""
|
||||
Determine the response type based on the request.
|
||||
a) For HTTP requests (e.g. via the browseable API) return a DRF response
|
||||
b) For AJAX requests, simply return a JSON rendered response.
|
||||
"""
|
||||
if page is not None:
|
||||
return self.get_paginated_response(data)
|
||||
elif request.is_ajax():
|
||||
return JsonResponse(data, safe=False)
|
||||
else:
|
||||
return Response(data)
|
||||
@ -1102,7 +1115,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
try:
|
||||
# Include or exclude pricing information in the serialized data
|
||||
kwargs['include_pricing'] = str2bool(self.request.GET.get('include_pricing', True))
|
||||
kwargs['include_pricing'] = self.include_pricing()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@ -1147,13 +1160,19 @@ class BomList(generics.ListCreateAPIView):
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
include_pricing = str2bool(params.get('include_pricing', True))
|
||||
|
||||
if include_pricing:
|
||||
if self.include_pricing():
|
||||
queryset = self.annotate_pricing(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
def include_pricing(self):
|
||||
"""
|
||||
Determine if pricing information should be included in the response
|
||||
"""
|
||||
pricing_default = InvenTreeSetting.get_setting('PART_SHOW_PRICE_IN_BOM')
|
||||
|
||||
return str2bool(self.request.query_params.get('include_pricing', pricing_default))
|
||||
|
||||
def annotate_pricing(self, queryset):
|
||||
"""
|
||||
Add part pricing information to the queryset
|
||||
@ -1262,6 +1281,35 @@ class BomItemValidate(generics.UpdateAPIView):
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class BomItemSubstituteList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for accessing a list of BomItemSubstitute objects
|
||||
"""
|
||||
|
||||
serializer_class = part_serializers.BomItemSubstituteSerializer
|
||||
queryset = BomItemSubstitute.objects.all()
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'part',
|
||||
'bom_item',
|
||||
]
|
||||
|
||||
|
||||
class BomItemSubstituteDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of a single BomItemSubstitute object
|
||||
"""
|
||||
|
||||
queryset = BomItemSubstitute.objects.all()
|
||||
serializer_class = part_serializers.BomItemSubstituteSerializer
|
||||
|
||||
|
||||
part_api_urls = [
|
||||
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
|
||||
|
||||
@ -1314,6 +1362,16 @@ part_api_urls = [
|
||||
]
|
||||
|
||||
bom_api_urls = [
|
||||
|
||||
url(r'^substitute/', include([
|
||||
|
||||
# Detail view
|
||||
url(r'^(?P<pk>\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
|
||||
|
||||
# Catch all
|
||||
url(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
|
||||
])),
|
||||
|
||||
# BOM Item Detail
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
|
||||
|
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():
|
||||
return reverse('api-bom-list')
|
||||
|
||||
def get_valid_parts_for_allocation(self):
|
||||
"""
|
||||
Return a list of valid parts which can be allocated against this BomItem:
|
||||
|
||||
- Include the referenced sub_part
|
||||
- Include any directly specvified substitute parts
|
||||
- If allow_variants is True, allow all variants of sub_part
|
||||
"""
|
||||
|
||||
# Set of parts we will allow
|
||||
parts = set()
|
||||
|
||||
parts.add(self.sub_part)
|
||||
|
||||
# Variant parts (if allowed)
|
||||
if self.allow_variants:
|
||||
for variant in self.sub_part.get_descendants(include_self=False):
|
||||
parts.add(variant)
|
||||
|
||||
# Substitute parts
|
||||
for sub in self.substitutes.all():
|
||||
parts.add(sub.part)
|
||||
|
||||
return parts
|
||||
|
||||
def is_stock_item_valid(self, stock_item):
|
||||
"""
|
||||
Check if the provided StockItem object is "valid" for assignment against this BomItem
|
||||
"""
|
||||
|
||||
return stock_item.part in self.get_valid_parts_for_allocation()
|
||||
|
||||
def get_stock_filter(self):
|
||||
"""
|
||||
Return a queryset filter for selecting StockItems which match this BomItem
|
||||
|
||||
- Allow stock from all directly specified substitute parts
|
||||
- If allow_variants is True, allow all part variants
|
||||
|
||||
"""
|
||||
|
||||
# Target part
|
||||
part = self.sub_part
|
||||
|
||||
if self.allow_variants:
|
||||
variants = part.get_descendants(include_self=True)
|
||||
return Q(part__in=[v.pk for v in variants])
|
||||
else:
|
||||
return Q(part=part)
|
||||
return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@ -2613,6 +2639,66 @@ class BomItem(models.Model):
|
||||
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
||||
|
||||
|
||||
class BomItemSubstitute(models.Model):
|
||||
"""
|
||||
A BomItemSubstitute provides a specification for alternative parts,
|
||||
which can be used in a bill of materials.
|
||||
|
||||
Attributes:
|
||||
bom_item: Link to the parent BomItem instance
|
||||
part: The part which can be used as a substitute
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("BOM Item Substitute")
|
||||
|
||||
# Prevent duplication of substitute parts
|
||||
unique_together = ('part', 'bom_item')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
self.full_clean()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""
|
||||
Ensure that this BomItemSubstitute is "unique":
|
||||
|
||||
- It cannot point to the same "part" as the "sub_part" of the parent "bom_item"
|
||||
"""
|
||||
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
if self.part == self.bom_item.sub_part:
|
||||
raise ValidationError({
|
||||
"part": _("Substitute part cannot be the same as the master part"),
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
return reverse('api-bom-substitute-list')
|
||||
|
||||
bom_item = models.ForeignKey(
|
||||
BomItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='substitutes',
|
||||
verbose_name=_('BOM Item'),
|
||||
help_text=_('Parent BOM item'),
|
||||
)
|
||||
|
||||
part = models.ForeignKey(
|
||||
Part,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='substitute_items',
|
||||
verbose_name=_('Part'),
|
||||
help_text=_('Substitute part'),
|
||||
limit_choices_to={
|
||||
'component': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PartRelated(models.Model):
|
||||
""" Store and handle related parts (eg. mating connector, crimps, etc.) """
|
||||
|
||||
|
@ -23,7 +23,8 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||
from .models import (BomItem, BomItemSubstitute,
|
||||
Part, PartAttachment, PartCategory,
|
||||
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
|
||||
PartInternalPriceBreak)
|
||||
@ -388,8 +389,27 @@ class PartStarSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class BomItemSubstituteSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for the BomItemSubstitute class
|
||||
"""
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
|
||||
|
||||
class Meta:
|
||||
model = BomItemSubstitute
|
||||
fields = [
|
||||
'pk',
|
||||
'bom_item',
|
||||
'part',
|
||||
'part_detail',
|
||||
]
|
||||
|
||||
|
||||
class BomItemSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for BomItem object """
|
||||
"""
|
||||
Serializer for BomItem object
|
||||
"""
|
||||
|
||||
price_range = serializers.CharField(read_only=True)
|
||||
|
||||
@ -397,6 +417,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
||||
|
||||
substitutes = BomItemSubstituteSerializer(many=True, read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True))
|
||||
@ -515,6 +537,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'reference',
|
||||
'sub_part',
|
||||
'sub_part_detail',
|
||||
'substitutes',
|
||||
'price_range',
|
||||
'validated',
|
||||
]
|
||||
|
@ -1,11 +1,12 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% if roles.part.change != True and editing_enabled %}
|
||||
{% if not roles.part.change %}
|
||||
<div class='alert alert-danger alert-block'>
|
||||
{% trans "You do not have permission to edit the BOM." %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
|
||||
{% if part.bom_checked_date %}
|
||||
{% if part.is_bom_valid %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
@ -23,42 +24,38 @@
|
||||
|
||||
<div id='bom-button-toolbar'>
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
{% if editing_enabled %}
|
||||
<button class='btn btn-default' type='button' title='{% trans "Remove selected BOM items" %}' id='bom-item-delete'>
|
||||
<span class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
<button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
|
||||
<span class='fas fa-file-upload'></span>
|
||||
</button>
|
||||
{% if part.variant_of %}
|
||||
<button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
|
||||
<span class='fas fa-clone'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
|
||||
<span class='fas fa-plus-circle'></span>
|
||||
</button>
|
||||
<button class='btn btn-success' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'>
|
||||
<span class='fas fa-check-circle'></span>
|
||||
</button>
|
||||
{% elif part.active %}
|
||||
<!-- Export menu -->
|
||||
<div class='btn-group'>
|
||||
<button id='export-options' title='{% trans "Export actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-download'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li>
|
||||
<li><a href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% if roles.part.change %}
|
||||
<button class='btn btn-primary' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'>
|
||||
<span class='fas fa-edit'></span>
|
||||
</button>
|
||||
{% if part.is_bom_valid == False %}
|
||||
<button class='btn btn-success' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'>
|
||||
<span class='fas fa-clipboard-check'></span>
|
||||
<!-- Action menu -->
|
||||
<div class='btn-group'>
|
||||
<button id='bom-actions' title='{% trans "BOM actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-wrench'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='bom-upload'><span class='fas fa-file-upload'></span> {% trans "Upload BOM" %}</a></li>
|
||||
{% if part.variant_of %}
|
||||
<li><a href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li>
|
||||
{% endif %}
|
||||
{% if not part.is_bom_valid %}
|
||||
<li><a href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li>
|
||||
{% endif %}
|
||||
<li><a href='#' id='bom-item-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Items" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class='btn btn-success' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default' id='download-bom' type='button'>
|
||||
<span class='fas fa-file-download'></span>
|
||||
</button>
|
||||
<button title='{% trans "Print BOM Report" %}' class='btn btn-default' id='print-bom-report' type='button'>
|
||||
<span class='fas fa-file-pdf'></span>
|
||||
</button>
|
||||
|
||||
<div class='filter-list' id='filter-list-bom'>
|
||||
<!-- Empty div (will be filled out with avilable BOM filters) -->
|
||||
</div>
|
||||
@ -67,4 +64,3 @@
|
||||
|
||||
<table class='table table-bom table-condensed' data-toolbar="#bom-button-toolbar" id='bom-table'>
|
||||
</table>
|
||||
{% endif %}
|
@ -473,7 +473,11 @@
|
||||
onPanelLoad("bom", function() {
|
||||
// Load the BOM table data
|
||||
loadBomTable($("#bom-table"), {
|
||||
editable: {{ editing_enabled }},
|
||||
{% if roles.part.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
editable: false,
|
||||
{% endif %}
|
||||
bom_url: "{% url 'api-bom-list' %}",
|
||||
part_url: "{% url 'api-part-list' %}",
|
||||
parent_id: {{ part.id }} ,
|
||||
@ -486,11 +490,6 @@
|
||||
]
|
||||
);
|
||||
|
||||
{% if editing_enabled %}
|
||||
$("#editing-finished").click(function() {
|
||||
location.href = "{% url 'part-detail' part.id %}?display=bom";
|
||||
});
|
||||
|
||||
$('#bom-item-delete').click(function() {
|
||||
|
||||
// Get a list of the selected BOM items
|
||||
@ -559,8 +558,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
{% else %}
|
||||
|
||||
$("#validate-bom").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'bom-validate' part.id %}",
|
||||
@ -570,10 +567,6 @@
|
||||
);
|
||||
});
|
||||
|
||||
$("#edit-bom").click(function () {
|
||||
location.href = "{% url 'part-detail' part.id %}?display=bom&edit=1";
|
||||
});
|
||||
|
||||
$("#download-bom").click(function () {
|
||||
launchModalForm("{% url 'bom-export' part.id %}",
|
||||
{
|
||||
@ -584,8 +577,6 @@
|
||||
);
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
$("#print-bom-report").click(function() {
|
||||
printBomReports([{{ part.pk }}]);
|
||||
});
|
||||
@ -629,10 +620,9 @@
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Load the BOM table data in the pricing view
|
||||
loadBomTable($("#bom-pricing-table"), {
|
||||
editable: {{ editing_enabled }},
|
||||
editable: false,
|
||||
bom_url: "{% url 'api-bom-list' %}",
|
||||
part_url: "{% url 'api-part-list' %}",
|
||||
parent_id: {{ part.id }} ,
|
||||
|
@ -1,4 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import PIL
|
||||
|
||||
@ -11,7 +12,8 @@ from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from stock.models import StockItem
|
||||
from part.models import BomItem, BomItemSubstitute
|
||||
from stock.models import StockItem, StockLocation
|
||||
from company.models import Company
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
@ -273,53 +275,6 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
def test_get_bom_list(self):
|
||||
""" There should be 4 BomItem objects in the database """
|
||||
url = reverse('api-bom-list')
|
||||
response = self.client.get(url, format='json')
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
def test_get_bom_detail(self):
|
||||
# Get the detail for a single BomItem
|
||||
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
|
||||
response = self.client.get(url, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(int(float(response.data['quantity'])), 25)
|
||||
|
||||
# Increase the quantity
|
||||
data = response.data
|
||||
data['quantity'] = 57
|
||||
data['note'] = 'Added a note'
|
||||
|
||||
response = self.client.patch(url, data, format='json')
|
||||
|
||||
# Check that the quantity was increased and a note added
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(int(float(response.data['quantity'])), 57)
|
||||
self.assertEqual(response.data['note'], 'Added a note')
|
||||
|
||||
def test_add_bom_item(self):
|
||||
url = reverse('api-bom-list')
|
||||
|
||||
data = {
|
||||
'part': 100,
|
||||
'sub_part': 4,
|
||||
'quantity': 777,
|
||||
}
|
||||
|
||||
response = self.client.post(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
# Now try to create a BomItem which points to a non-assembly part (should fail)
|
||||
data['part'] = 3
|
||||
response = self.client.post(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# TODO - Now try to create a BomItem which references itself
|
||||
data['part'] = 2
|
||||
data['sub_part'] = 2
|
||||
response = self.client.post(url, data, format='json')
|
||||
|
||||
def test_test_templates(self):
|
||||
|
||||
url = reverse('api-part-test-template-list')
|
||||
@ -926,6 +881,249 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(data['stock_item_count'], 105)
|
||||
|
||||
|
||||
class BomItemTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Unit tests for the BomItem API
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'stock',
|
||||
'bom',
|
||||
'company',
|
||||
]
|
||||
|
||||
roles = [
|
||||
'part.add',
|
||||
'part.change',
|
||||
'part.delete',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_bom_list(self):
|
||||
"""
|
||||
Tests for the BomItem list endpoint
|
||||
"""
|
||||
|
||||
# How many BOM items currently exist in the database?
|
||||
n = BomItem.objects.count()
|
||||
|
||||
url = reverse('api-bom-list')
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(response.data), n)
|
||||
|
||||
# Now, filter by part
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'part': 100,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
print("results:", len(response.data))
|
||||
|
||||
def test_get_bom_detail(self):
|
||||
"""
|
||||
Get the detail view for a single BomItem object
|
||||
"""
|
||||
|
||||
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
self.assertEqual(int(float(response.data['quantity'])), 25)
|
||||
|
||||
# Increase the quantity
|
||||
data = response.data
|
||||
data['quantity'] = 57
|
||||
data['note'] = 'Added a note'
|
||||
|
||||
response = self.patch(url, data, expected_code=200)
|
||||
|
||||
self.assertEqual(int(float(response.data['quantity'])), 57)
|
||||
self.assertEqual(response.data['note'], 'Added a note')
|
||||
|
||||
def test_add_bom_item(self):
|
||||
"""
|
||||
Test that we can create a new BomItem via the API
|
||||
"""
|
||||
|
||||
url = reverse('api-bom-list')
|
||||
|
||||
data = {
|
||||
'part': 100,
|
||||
'sub_part': 4,
|
||||
'quantity': 777,
|
||||
}
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
|
||||
# Now try to create a BomItem which references itself
|
||||
data['part'] = 100
|
||||
data['sub_part'] = 100
|
||||
self.client.post(url, data, expected_code=400)
|
||||
|
||||
def test_variants(self):
|
||||
"""
|
||||
Tests for BomItem use with variants
|
||||
"""
|
||||
|
||||
stock_url = reverse('api-stock-list')
|
||||
|
||||
# BOM item we are interested in
|
||||
bom_item = BomItem.objects.get(pk=1)
|
||||
|
||||
bom_item.allow_variants = True
|
||||
bom_item.save()
|
||||
|
||||
# sub part that the BOM item points to
|
||||
sub_part = bom_item.sub_part
|
||||
|
||||
sub_part.is_template = True
|
||||
sub_part.save()
|
||||
|
||||
# How many stock items are initially available for this part?
|
||||
response = self.get(
|
||||
stock_url,
|
||||
{
|
||||
'bom_item': bom_item.pk,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
n_items = len(response.data)
|
||||
self.assertEqual(n_items, 2)
|
||||
|
||||
loc = StockLocation.objects.get(pk=1)
|
||||
|
||||
# Now we will create some variant parts and stock
|
||||
for ii in range(5):
|
||||
|
||||
# Create a variant part!
|
||||
variant = Part.objects.create(
|
||||
name=f"Variant_{ii}",
|
||||
description="A variant part",
|
||||
component=True,
|
||||
variant_of=sub_part
|
||||
)
|
||||
|
||||
variant.save()
|
||||
|
||||
Part.objects.rebuild()
|
||||
|
||||
# Create some stock items for this new part
|
||||
for jj in range(ii):
|
||||
StockItem.objects.create(
|
||||
part=variant,
|
||||
location=loc,
|
||||
quantity=100
|
||||
)
|
||||
|
||||
# Keep track of running total
|
||||
n_items += ii
|
||||
|
||||
# Now, there should be more stock items available!
|
||||
response = self.get(
|
||||
stock_url,
|
||||
{
|
||||
'bom_item': bom_item.pk,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), n_items)
|
||||
|
||||
# Now, disallow variant parts in the BomItem
|
||||
bom_item.allow_variants = False
|
||||
bom_item.save()
|
||||
|
||||
# There should now only be 2 stock items available again
|
||||
response = self.get(
|
||||
stock_url,
|
||||
{
|
||||
'bom_item': bom_item.pk,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_substitutes(self):
|
||||
"""
|
||||
Tests for BomItem substitutes
|
||||
"""
|
||||
|
||||
url = reverse('api-bom-substitute-list')
|
||||
stock_url = reverse('api-stock-list')
|
||||
|
||||
# Initially we have no substitute parts
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
# BOM item we are interested in
|
||||
bom_item = BomItem.objects.get(pk=1)
|
||||
|
||||
# Filter stock items which can be assigned against this stock item
|
||||
response = self.get(
|
||||
stock_url,
|
||||
{
|
||||
"bom_item": bom_item.pk,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
n_items = len(response.data)
|
||||
|
||||
loc = StockLocation.objects.get(pk=1)
|
||||
|
||||
# Let's make some!
|
||||
for ii in range(5):
|
||||
sub_part = Part.objects.create(
|
||||
name=f"Substitute {ii}",
|
||||
description="A substitute part",
|
||||
component=True,
|
||||
is_template=False,
|
||||
assembly=False
|
||||
)
|
||||
|
||||
# Create a new StockItem for this Part
|
||||
StockItem.objects.create(
|
||||
part=sub_part,
|
||||
quantity=1000,
|
||||
location=loc,
|
||||
)
|
||||
|
||||
# Now, create an "alternative" for the BOM Item
|
||||
BomItemSubstitute.objects.create(
|
||||
bom_item=bom_item,
|
||||
part=sub_part
|
||||
)
|
||||
|
||||
# We should be able to filter the API list to just return this new part
|
||||
response = self.get(url, data={'part': sub_part.pk}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# We should also have more stock available to allocate against this BOM item!
|
||||
response = self.get(
|
||||
stock_url,
|
||||
{
|
||||
"bom_item": bom_item.pk,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), n_items + ii + 1)
|
||||
|
||||
# There should now be 5 substitute parts available in the database
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
|
||||
class PartParameterTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Tests for the ParParameter API
|
||||
|
@ -1,8 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
from django.db import transaction
|
||||
|
||||
from django.test import TestCase
|
||||
import django.core.exceptions as django_exceptions
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import Part, BomItem
|
||||
from .models import Part, BomItem, BomItemSubstitute
|
||||
|
||||
|
||||
class BomItemTest(TestCase):
|
||||
@ -130,3 +135,67 @@ class BomItemTest(TestCase):
|
||||
self.bob.get_bom_price_range(1, internal=True),
|
||||
(Decimal(27.5), Decimal(87.5))
|
||||
)
|
||||
|
||||
def test_substitutes(self):
|
||||
"""
|
||||
Tests for BOM item substitutes
|
||||
"""
|
||||
|
||||
# We will make some subtitute parts for the "orphan" part
|
||||
bom_item = BomItem.objects.get(
|
||||
part=self.bob,
|
||||
sub_part=self.orphan
|
||||
)
|
||||
|
||||
# No substitute parts available
|
||||
self.assertEqual(bom_item.substitutes.count(), 0)
|
||||
|
||||
subs = []
|
||||
|
||||
for ii in range(5):
|
||||
|
||||
# Create a new part
|
||||
sub_part = Part.objects.create(
|
||||
name=f"Orphan {ii}",
|
||||
description="A substitute part for the orphan part",
|
||||
component=True,
|
||||
is_template=False,
|
||||
assembly=False,
|
||||
)
|
||||
|
||||
subs.append(sub_part)
|
||||
|
||||
# Link it as a substitute part
|
||||
BomItemSubstitute.objects.create(
|
||||
bom_item=bom_item,
|
||||
part=sub_part
|
||||
)
|
||||
|
||||
# Try to link it again (this should fail as it is a duplicate substitute)
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
with transaction.atomic():
|
||||
BomItemSubstitute.objects.create(
|
||||
bom_item=bom_item,
|
||||
part=sub_part
|
||||
)
|
||||
|
||||
# There should be now 5 substitute parts available
|
||||
self.assertEqual(bom_item.substitutes.count(), 5)
|
||||
|
||||
# Try to create a substitute which points to the same sub-part (should fail)
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
BomItemSubstitute.objects.create(
|
||||
bom_item=bom_item,
|
||||
part=self.orphan,
|
||||
)
|
||||
|
||||
# Remove one substitute part
|
||||
bom_item.substitutes.last().delete()
|
||||
|
||||
self.assertEqual(bom_item.substitutes.count(), 4)
|
||||
|
||||
for sub in subs:
|
||||
sub.delete()
|
||||
|
||||
# The substitution links should have been automatically removed
|
||||
self.assertEqual(bom_item.substitutes.count(), 0)
|
||||
|
@ -87,16 +87,6 @@ class PartDetailTest(PartViewTestCase):
|
||||
self.assertEqual(response.context['part'].pk, pk)
|
||||
self.assertEqual(response.context['category'], part.category)
|
||||
|
||||
self.assertFalse(response.context['editing_enabled'])
|
||||
|
||||
def test_editable(self):
|
||||
|
||||
pk = 1
|
||||
response = self.client.get(reverse('part-detail', args=(pk,)), {'edit': True})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.context['editing_enabled'])
|
||||
|
||||
def test_part_detail_from_ipn(self):
|
||||
"""
|
||||
Test that we can retrieve a part detail page from part IPN:
|
||||
|
@ -404,20 +404,13 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
||||
|
||||
# Add in some extra context information based on query params
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Provide extra context data to template
|
||||
|
||||
- If '?editing=True', set 'editing_enabled' context variable
|
||||
"""
|
||||
Provide extra context data to template
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
part = self.get_object()
|
||||
|
||||
if str2bool(self.request.GET.get('edit', '')):
|
||||
# Allow BOM editing if the part is active
|
||||
context['editing_enabled'] = 1 if part.active else 0
|
||||
else:
|
||||
context['editing_enabled'] = 0
|
||||
|
||||
ctx = part.get_context_data(self.request)
|
||||
context.update(**ctx)
|
||||
|
||||
|
@ -72,7 +72,9 @@
|
||||
{% if barcodes %}
|
||||
<!-- Barcode actions menu -->
|
||||
<div class='btn-group'>
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><
|
||||
span class='fas fa-qrcode'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if roles.stock.change %}
|
||||
|
@ -143,6 +143,146 @@ function newPartFromBomWizard(e) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Launch a modal dialog displaying the "substitute parts" for a particular BomItem
|
||||
*
|
||||
* If editable, allows substitutes to be added and deleted
|
||||
*/
|
||||
function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
|
||||
|
||||
// Reload data for the parent table
|
||||
function reloadParentTable() {
|
||||
if (options.table) {
|
||||
options.table.bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubstituteRow(substitute) {
|
||||
|
||||
var pk = substitute.pk;
|
||||
|
||||
var part = substitute.part_detail;
|
||||
|
||||
var thumb = thumbnailImage(part.thumbnail || part.image);
|
||||
|
||||
var buttons = '';
|
||||
|
||||
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove substitute part" %}');
|
||||
|
||||
// Render a single row
|
||||
var html = `
|
||||
<tr id='substitute-row-${pk}' class='substitute-row'>
|
||||
<td id='part-${pk}'>
|
||||
<a href='/part/${part.pk}/'>
|
||||
${thumb} ${part.full_name}
|
||||
</a>
|
||||
</td>
|
||||
<td id='description-${pk}'><em>${part.description}</em></td>
|
||||
<td>${buttons}</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Construct a table to render the rows
|
||||
var rows = '';
|
||||
|
||||
substitutes.forEach(function(sub) {
|
||||
rows += renderSubstituteRow(sub);
|
||||
});
|
||||
|
||||
var html = `
|
||||
<table class='table table-striped table-condensed' id='substitute-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th><!-- Actions --></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
html += `
|
||||
<div class='alert alert-success alert-block'>
|
||||
{% trans "Select and add a new variant item using the input below" %}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add a callback to remove a row from the table
|
||||
function addRemoveCallback(modal, element) {
|
||||
$(modal).find(element).click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var pre = `
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "Are you sure you wish to remove this substitute part link?" %}
|
||||
</div>
|
||||
`;
|
||||
|
||||
constructForm(`/api/bom/substitute/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Remove Substitute Part" %}',
|
||||
preFormContent: pre,
|
||||
confirm: true,
|
||||
onSuccess: function() {
|
||||
$(modal).find(`#substitute-row-${pk}`).remove();
|
||||
reloadParentTable();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
constructForm('{% url "api-bom-substitute-list" %}', {
|
||||
method: 'POST',
|
||||
fields: {
|
||||
bom_item: {
|
||||
hidden: true,
|
||||
value: bom_item_id,
|
||||
},
|
||||
part: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
preFormContent: html,
|
||||
cancelText: '{% trans "Close" %}',
|
||||
submitText: '{% trans "Add Substitute" %}',
|
||||
title: '{% trans "Edit BOM Item Substitutes" %}',
|
||||
afterRender: function(fields, opts) {
|
||||
addRemoveCallback(opts.modal, '.button-row-remove');
|
||||
},
|
||||
preventClose: true,
|
||||
onSuccess: function(response, opts) {
|
||||
|
||||
// Clear the form
|
||||
var field = {
|
||||
type: 'related field',
|
||||
};
|
||||
|
||||
updateFieldValue('part', null, field, opts);
|
||||
|
||||
// Add the new substitute to the table
|
||||
var row = renderSubstituteRow(response);
|
||||
$(opts.modal).find('#substitute-table > tbody:last-child').append(row);
|
||||
|
||||
// Add a callback to the new button
|
||||
addRemoveCallback(opts.modal, `#button-row-remove-${response.pk}`);
|
||||
|
||||
// Re-enable the "submit" button
|
||||
$(opts.modal).find('#modal-form-submit').prop('disabled', false);
|
||||
|
||||
// Reload the parent BOM table
|
||||
reloadParentTable();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function loadBomTable(table, options) {
|
||||
/* Load a BOM table with some configurable options.
|
||||
*
|
||||
@ -229,6 +369,14 @@ function loadBomTable(table, options) {
|
||||
|
||||
html += makePartIcons(row.sub_part_detail);
|
||||
|
||||
if (row.substitutes && row.substitutes.length > 0) {
|
||||
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitutes Available" %}');
|
||||
}
|
||||
|
||||
if (row.allow_variants) {
|
||||
html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}');
|
||||
}
|
||||
|
||||
// Display an extra icon if this part is an assembly
|
||||
if (sub_part.assembly) {
|
||||
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
|
||||
@ -300,6 +448,20 @@ function loadBomTable(table, options) {
|
||||
return renderLink(text, url);
|
||||
}
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'substitutes',
|
||||
title: '{% trans "Substitutes" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
if (row.substitutes && row.substitutes.length > 0) {
|
||||
return row.substitutes.length;
|
||||
} else {
|
||||
return `-`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (show_pricing) {
|
||||
cols.push({
|
||||
@ -420,18 +582,17 @@ function loadBomTable(table, options) {
|
||||
|
||||
if (row.part == options.parent_id) {
|
||||
|
||||
var bValidate = `<button title='{% trans "Validate BOM Item" %}' class='bom-validate-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-check-circle icon-blue'/></button>`;
|
||||
var bValidate = makeIconButton('fa-check-circle icon-green', 'bom-validate-button', row.pk, '{% trans "Validate BOM Item" %}');
|
||||
|
||||
var bValid = `<span title='{% trans "This line has been validated" %}' class='fas fa-check-double icon-green'/>`;
|
||||
|
||||
var bEdit = `<button title='{% trans "Edit BOM Item" %}' class='bom-edit-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-edit'></span></button>`;
|
||||
var bSubs = makeIconButton('fa-exchange-alt icon-blue', 'bom-substitutes-button', row.pk, '{% trans "Edit substitute parts" %}');
|
||||
|
||||
var bDelt = `<button title='{% trans "Delete BOM Item" %}' class='bom-delete-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-trash-alt icon-red'></span></button>`;
|
||||
var bEdit = makeIconButton('fa-edit icon-blue', 'bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}');
|
||||
|
||||
var html = `<div class='btn-group' role='group'>`;
|
||||
var bDelt = makeIconButton('fa-trash-alt icon-red', 'bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}');
|
||||
|
||||
html += bEdit;
|
||||
html += bDelt;
|
||||
var html = `<div class='btn-group float-right' role='group' style='min-width: 100px;'>`;
|
||||
|
||||
if (!row.validated) {
|
||||
html += bValidate;
|
||||
@ -439,6 +600,10 @@ function loadBomTable(table, options) {
|
||||
html += bValid;
|
||||
}
|
||||
|
||||
html += bEdit;
|
||||
html += bSubs;
|
||||
html += bDelt;
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
@ -490,6 +655,7 @@ function loadBomTable(table, options) {
|
||||
treeEnable: !options.editable,
|
||||
rootParentId: parent_id,
|
||||
idField: 'pk',
|
||||
uniqueId: 'pk',
|
||||
parentIdField: 'parentId',
|
||||
treeShowField: 'sub_part',
|
||||
showColumns: true,
|
||||
@ -566,19 +732,27 @@ function loadBomTable(table, options) {
|
||||
// In editing mode, attached editables to the appropriate table elements
|
||||
if (options.editable) {
|
||||
|
||||
// Callback for "delete" button
|
||||
table.on('click', '.bom-delete-button', function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var html = `
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "Are you sure you want to delete this BOM item?" %}
|
||||
</div>`;
|
||||
|
||||
constructForm(`/api/bom/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete BOM Item" %}',
|
||||
preFormContent: html,
|
||||
onSuccess: function() {
|
||||
reloadBomTable(table);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Callback for "edit" button
|
||||
table.on('click', '.bom-edit-button', function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
@ -595,6 +769,7 @@ function loadBomTable(table, options) {
|
||||
});
|
||||
});
|
||||
|
||||
// Callback for "validate" button
|
||||
table.on('click', '.bom-validate-button', function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
@ -613,5 +788,21 @@ function loadBomTable(table, options) {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Callback for "substitutes" button
|
||||
table.on('click', '.bom-substitutes-button', function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var row = table.bootstrapTable('getRowByUniqueId', pk);
|
||||
var subs = row.substitutes || [];
|
||||
|
||||
bomSubstitutesDialog(
|
||||
pk,
|
||||
subs,
|
||||
{
|
||||
table: table,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -208,15 +208,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(
|
||||
`/build/${buildId}/unallocate/`,
|
||||
{
|
||||
success: reloadTable,
|
||||
data: {
|
||||
output: pk,
|
||||
}
|
||||
}
|
||||
);
|
||||
unallocateStock(buildId, {
|
||||
output: pk,
|
||||
table: table,
|
||||
});
|
||||
});
|
||||
|
||||
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
|
||||
@ -236,6 +231,49 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Unallocate stock against a particular build order
|
||||
*
|
||||
* Options:
|
||||
* - output: pk value for a stock item "build output"
|
||||
* - bom_item: pk value for a particular BOMItem (build item)
|
||||
*/
|
||||
function unallocateStock(build_id, options={}) {
|
||||
|
||||
var url = `/api/build/${build_id}/unallocate/`;
|
||||
|
||||
var html = `
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "Are you sure you wish to unallocate stock items from this build?" %}
|
||||
</dvi>
|
||||
`;
|
||||
|
||||
constructForm(url, {
|
||||
method: 'POST',
|
||||
confirm: true,
|
||||
preFormContent: html,
|
||||
fields: {
|
||||
output: {
|
||||
hidden: true,
|
||||
value: options.output,
|
||||
},
|
||||
bom_item: {
|
||||
hidden: true,
|
||||
value: options.bom_item,
|
||||
},
|
||||
},
|
||||
title: '{% trans "Unallocate Stock Items" %}',
|
||||
onSuccess: function(response, opts) {
|
||||
if (options.table) {
|
||||
// Reload the parent table
|
||||
$(options.table).bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function loadBuildOrderAllocationTable(table, options={}) {
|
||||
/**
|
||||
* Load a table showing all the BuildOrder allocations for a given part
|
||||
@ -348,6 +386,17 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
table = `#allocation-table-${outputId}`;
|
||||
}
|
||||
|
||||
// Filters
|
||||
var filters = loadTableFilters('builditems');
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
setupFilterList('builditems', $(table), options.filterTarget || null);
|
||||
|
||||
// If an "output" is specified, then only "trackable" parts are allocated
|
||||
// Otherwise, only "untrackable" parts are allowed
|
||||
var trackable = ! !output;
|
||||
@ -458,17 +507,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
|
||||
// Callback for 'unallocate' button
|
||||
$(table).find('.button-unallocate').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(`/build/${buildId}/unallocate/`,
|
||||
{
|
||||
success: reloadTable,
|
||||
data: {
|
||||
output: outputId,
|
||||
part: pk,
|
||||
}
|
||||
}
|
||||
);
|
||||
// Extract row data from the table
|
||||
var idx = $(this).closest('tr').attr('data-index');
|
||||
var row = $(table).bootstrapTable('getData')[idx];
|
||||
|
||||
unallocateStock(buildId, {
|
||||
bom_item: row.pk,
|
||||
output: outputId == 'untracked' ? null : outputId,
|
||||
table: table,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -726,6 +774,14 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
|
||||
html += makePartIcons(row.sub_part_detail);
|
||||
|
||||
if (row.substitutes && row.substitutes.length > 0) {
|
||||
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitute parts available" %}');
|
||||
}
|
||||
|
||||
if (row.allow_variants) {
|
||||
html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}');
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
@ -1021,12 +1077,12 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
filters: {
|
||||
bom_item: bom_item.pk,
|
||||
in_stock: true,
|
||||
part_detail: false,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
model: 'stockitem',
|
||||
required: true,
|
||||
render_part_detail: false,
|
||||
render_part_detail: true,
|
||||
render_location_detail: true,
|
||||
auto_fill: true,
|
||||
adjustFilters: function(filters) {
|
||||
|
@ -283,18 +283,21 @@ function setupFilterList(tableKey, table, target) {
|
||||
|
||||
element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-default filter-tag'><span class='fas fa-redo-alt'></span></button>`);
|
||||
|
||||
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`);
|
||||
// If there are available filters, add them in!
|
||||
if (filters.length > 0) {
|
||||
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`);
|
||||
|
||||
if (Object.keys(filters).length > 0) {
|
||||
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-default filter-tag'><span class='fas fa-trash-alt'></span></button>`);
|
||||
}
|
||||
if (Object.keys(filters).length > 0) {
|
||||
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-default filter-tag'><span class='fas fa-trash-alt'></span></button>`);
|
||||
}
|
||||
|
||||
for (var key in filters) {
|
||||
var value = getFilterOptionValue(tableKey, key, filters[key]);
|
||||
var title = getFilterTitle(tableKey, key);
|
||||
var description = getFilterDescription(tableKey, key);
|
||||
for (var key in filters) {
|
||||
var value = getFilterOptionValue(tableKey, key, filters[key]);
|
||||
var title = getFilterTitle(tableKey, key);
|
||||
var description = getFilterDescription(tableKey, key);
|
||||
|
||||
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
|
||||
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for reloading the table
|
||||
|
@ -52,8 +52,6 @@ function renderStockItem(name, data, parameters, options) {
|
||||
if (data.part_detail) {
|
||||
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
var render_part_detail = true;
|
||||
|
||||
@ -61,23 +59,10 @@ function renderStockItem(name, data, parameters, options) {
|
||||
render_part_detail = parameters['render_part_detail'];
|
||||
}
|
||||
|
||||
var part_detail = '';
|
||||
|
||||
if (render_part_detail) {
|
||||
html += `<img src='${image}' class='select2-thumbnail'>`;
|
||||
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
|
||||
}
|
||||
|
||||
html += '<span>';
|
||||
|
||||
if (data.serial && data.quantity == 1) {
|
||||
html += `{% trans "Serial Number" %}: ${data.serial}`;
|
||||
} else {
|
||||
html += `{% trans "Quantity" %}: ${data.quantity}`;
|
||||
}
|
||||
|
||||
html += '</span>';
|
||||
|
||||
if (render_part_detail && data.part_detail.description) {
|
||||
html += `<p><small>${data.part_detail.description}</small></p>`;
|
||||
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
|
||||
}
|
||||
|
||||
var render_stock_id = true;
|
||||
@ -86,8 +71,10 @@ function renderStockItem(name, data, parameters, options) {
|
||||
render_stock_id = parameters['render_stock_id'];
|
||||
}
|
||||
|
||||
var stock_id = '';
|
||||
|
||||
if (render_stock_id) {
|
||||
html += `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
|
||||
stock_id = `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
|
||||
}
|
||||
|
||||
var render_location_detail = false;
|
||||
@ -96,10 +83,28 @@ function renderStockItem(name, data, parameters, options) {
|
||||
render_location_detail = parameters['render_location_detail'];
|
||||
}
|
||||
|
||||
var location_detail = '';
|
||||
|
||||
if (render_location_detail && data.location_detail) {
|
||||
html += `<span> - ${data.location_detail.name}</span>`;
|
||||
location_detail = ` - (<em>${data.location_detail.name}</em>)`;
|
||||
}
|
||||
|
||||
var stock_detail = '';
|
||||
|
||||
if (data.serial && data.quantity == 1) {
|
||||
stock_detail = `{% trans "Serial Number" %}: ${data.serial}`;
|
||||
} else if (data.quantity == 0) {
|
||||
stock_detail = `<span class='label-form label-red'>{% trans "No Stock"% }</span>`;
|
||||
} else {
|
||||
stock_detail = `{% trans "Quantity" %}: ${data.quantity}`;
|
||||
}
|
||||
|
||||
var html = `
|
||||
<span>
|
||||
${part_detail}${stock_detail}${location_detail}${stock_id}
|
||||
</span>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
@ -159,21 +164,25 @@ function renderPart(name, data, parameters, options) {
|
||||
html += ` - <i>${data.description}</i>`;
|
||||
}
|
||||
|
||||
var stock = '';
|
||||
var extra = '';
|
||||
|
||||
// Display available part quantity
|
||||
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
|
||||
if (data.in_stock == 0) {
|
||||
stock = `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
|
||||
extra += `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
|
||||
} else {
|
||||
stock = `<span class='label-form label-green'>{% trans "In Stock" %}: ${data.in_stock}</span>`;
|
||||
extra += `<span class='label-form label-green'>{% trans "Stock" %}: ${data.in_stock}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.active) {
|
||||
extra += `<span class='label-form label-red'>{% trans "Inactive" %}</span>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<span class='float-right'>
|
||||
<small>
|
||||
${stock}
|
||||
${extra}
|
||||
{% trans "Part ID" %}: ${data.pk}
|
||||
</small>
|
||||
</span>`;
|
||||
|
@ -592,7 +592,7 @@ function loadPartParameterTable(table, url, options) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
// setupFilterLsit("#part-parameters", $(table));
|
||||
// setupFilterList("#part-parameters", $(table));
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: url,
|
||||
|
@ -81,6 +81,7 @@ class RuleSet(models.Model):
|
||||
'part': [
|
||||
'part_part',
|
||||
'part_bomitem',
|
||||
'part_bomitemsubstitute',
|
||||
'part_partattachment',
|
||||
'part_partsellpricebreak',
|
||||
'part_partinternalpricebreak',
|
||||
@ -110,6 +111,7 @@ class RuleSet(models.Model):
|
||||
'part_part',
|
||||
'part_partcategory',
|
||||
'part_bomitem',
|
||||
'part_bomitemsubstitute',
|
||||
'build_build',
|
||||
'build_builditem',
|
||||
'build_buildorderattachment',
|
||||
|
Loading…
Reference in New Issue
Block a user