diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 57ffe88cf3..310d4d7f09 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -232,6 +232,29 @@ class BuildUnallocate(generics.CreateAPIView): return ctx +class BuildOutputCreate(generics.CreateAPIView): + """ + API endpoint for creating new build output(s) + """ + + queryset = Build.objects.none() + + serializer_class = build.serializers.BuildOutputCreateSerializer + + def get_serializer_context(self): + ctx = super().get_serializer_context() + + ctx['request'] = self.request + ctx['to_complete'] = True + + try: + ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return ctx + + class BuildOutputComplete(generics.CreateAPIView): """ API endpoint for completing build outputs @@ -455,6 +478,7 @@ build_api_urls = [ url(r'^(?P\d+)/', include([ url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), + url(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'), url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'), url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'), url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index d242586b3c..eaae0636c9 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -14,51 +14,6 @@ from InvenTree.forms import HelperForm from .models import Build -class BuildOutputCreateForm(HelperForm): - """ - Form for creating a new build output. - """ - - def __init__(self, *args, **kwargs): - - build = kwargs.pop('build', None) - - if build: - self.field_placeholder['serial_numbers'] = build.part.getSerialNumberString() - - super().__init__(*args, **kwargs) - - field_prefix = { - 'serial_numbers': 'fa-hashtag', - } - - output_quantity = forms.IntegerField( - label=_('Quantity'), - help_text=_('Enter quantity for build output'), - ) - - serial_numbers = forms.CharField( - label=_('Serial Numbers'), - required=False, - help_text=_('Enter serial numbers for build outputs'), - ) - - confirm = forms.BooleanField( - required=True, - label=_('Confirm'), - help_text=_('Confirm creation of build output'), - ) - - class Meta: - model = Build - fields = [ - 'output_quantity', - 'batch', - 'serial_numbers', - 'confirm', - ] - - class CancelBuildForm(HelperForm): """ Form for cancelling a build """ diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index e5bb812083..74b75787e7 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -646,11 +646,13 @@ class Build(MPTTModel, ReferenceIndexingMixin): batch: Override batch code serials: Serial numbers location: Override location + auto_allocate: Automatically allocate stock with matching serial numbers """ batch = kwargs.get('batch', self.batch) location = kwargs.get('location', self.destination) serials = kwargs.get('serials', None) + auto_allocate = kwargs.get('auto_allocate', False) """ Determine if we can create a single output (with quantity > 0), @@ -672,6 +674,9 @@ class Build(MPTTModel, ReferenceIndexingMixin): Create multiple build outputs with a single quantity of 1 """ + # Quantity *must* be an integer at this point! + quantity = int(quantity) + for ii in range(quantity): if serials: @@ -679,7 +684,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): else: serial = None - StockModels.StockItem.objects.create( + output = StockModels.StockItem.objects.create( quantity=1, location=location, part=self.part, @@ -689,6 +694,37 @@ class Build(MPTTModel, ReferenceIndexingMixin): is_building=True, ) + if auto_allocate and serial is not None: + + # Get a list of BomItem objects which point to "trackable" parts + + for bom_item in self.part.get_trackable_parts(): + + parts = bom_item.get_valid_parts_for_allocation() + + for part in parts: + + items = StockModels.StockItem.objects.filter( + part=part, + serial=str(serial), + quantity=1, + ).filter(StockModels.StockItem.IN_STOCK_FILTER) + + """ + Test if there is a matching serial number! + """ + if items.exists() and items.count() == 1: + stock_item = items[0] + + # Allocate the stock item + BuildItem.objects.create( + build=self, + bom_item=bom_item, + stock_item=stock_item, + quantity=quantity, + install_into=output, + ) + else: """ Create a single build output of the given quantity diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index bc9d018cbe..0b0858cb8a 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -19,6 +19,7 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin import InvenTree.helpers +from InvenTree.helpers import extract_serial_numbers from InvenTree.serializers import InvenTreeDecimalField from InvenTree.status_codes import StockStatus @@ -170,6 +171,137 @@ class BuildOutputSerializer(serializers.Serializer): ] +class BuildOutputCreateSerializer(serializers.Serializer): + """ + Serializer for creating a new BuildOutput against a BuildOrder. + + URL pattern is "/api/build//create-output/", where is the PK of a Build. + + The Build object is provided to the serializer context. + """ + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True, + label=_('Quantity'), + help_text=_('Enter quantity for build output'), + ) + + def get_build(self): + return self.context["build"] + + def get_part(self): + return self.get_build().part + + def validate_quantity(self, quantity): + + if quantity < 0: + raise ValidationError(_("Quantity must be greater than zero")) + + part = self.get_part() + + if int(quantity) != quantity: + # Quantity must be an integer value if the part being built is trackable + if part.trackable: + raise ValidationError(_("Integer quantity required for trackable parts")) + + if part.has_trackable_parts(): + raise ValidationError(_("Integer quantity required, as the bill of materials contains tracakble parts")) + + return quantity + + batch_code = serializers.CharField( + required=False, + allow_blank=True, + label=_('Batch Code'), + help_text=_('Batch code for this build output'), + ) + + serial_numbers = serializers.CharField( + allow_blank=True, + required=False, + label=_('Serial Numbers'), + help_text=_('Enter serial numbers for build outputs'), + ) + + def validate_serial_numbers(self, serial_numbers): + + serial_numbers = serial_numbers.strip() + + # TODO: Field level validation necessary here? + return serial_numbers + + auto_allocate = serializers.BooleanField( + required=False, + default=False, + label=_('Auto Allocate Serial Numbers'), + help_text=_('Automatically allocate required items with matching serial numbers'), + ) + + def validate(self, data): + """ + Perform form validation + """ + + part = self.get_part() + + # Cache a list of serial numbers (to be used in the "save" method) + self.serials = None + + quantity = data['quantity'] + serial_numbers = data.get('serial_numbers', '') + + if serial_numbers: + + try: + self.serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt()) + except DjangoValidationError as e: + raise ValidationError({ + 'serial_numbers': e.messages, + }) + + # Check for conflicting serial numbesr + existing = [] + + for serial in self.serials: + if part.checkIfSerialNumberExists(serial): + existing.append(serial) + + if len(existing) > 0: + + msg = _("The following serial numbers already exist") + msg += " : " + msg += ",".join([str(e) for e in existing]) + + raise ValidationError({ + 'serial_numbers': msg, + }) + + return data + + def save(self): + """ + Generate the new build output(s) + """ + + data = self.validated_data + + quantity = data['quantity'] + batch_code = data.get('batch_code', '') + auto_allocate = data.get('auto_allocate', False) + + build = self.get_build() + + build.create_build_output( + quantity, + serials=self.serials, + batch=batch_code, + auto_allocate=auto_allocate, + ) + + class BuildOutputDeleteSerializer(serializers.Serializer): """ DRF serializer for deleting (cancelling) one or more build outputs diff --git a/InvenTree/build/templates/build/build_output_create.html b/InvenTree/build/templates/build/build_output_create.html deleted file mode 100644 index 5de41695a6..0000000000 --- a/InvenTree/build/templates/build/build_output_create.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} -{% block pre_form_content %} - -{% if build.part.has_trackable_parts %} -
- {% trans "The Bill of Materials contains trackable parts" %}
- {% trans "Build outputs must be generated individually." %}
- {% trans "Multiple build outputs will be created based on the quantity specified." %} -
-{% endif %} - -{% if build.part.trackable %} -
- {% trans "Trackable parts can have serial numbers specified" %}
- {% trans "Enter serial numbers to generate multiple single build outputs" %} -
-{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index ff335d139c..28760c5316 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -321,9 +321,11 @@ {{ block.super }} $('#btn-create-output').click(function() { - launchModalForm('{% url "build-output-create" build.id %}', + + createBuildOutput( + {{ build.pk }}, { - reload: true, + trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%}, } ); }); diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 30a9470ee2..0058e8a527 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -9,7 +9,6 @@ from . import views build_detail_urls = [ url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'), url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), - url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), ] diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index a8cf72f5a6..37fb024940 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -6,16 +6,14 @@ Django views for interacting with Build objects from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ -from django.core.exceptions import ValidationError from django.views.generic import DetailView, ListView -from django.forms import HiddenInput from .models import Build from . import forms from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import InvenTreeRoleMixin -from InvenTree.helpers import str2bool, extract_serial_numbers +from InvenTree.helpers import str2bool from InvenTree.status_codes import BuildStatus @@ -76,121 +74,6 @@ class BuildCancel(AjaxUpdateView): } -class BuildOutputCreate(AjaxUpdateView): - """ - Create a new build output (StockItem) for a given build. - """ - - model = Build - form_class = forms.BuildOutputCreateForm - ajax_template_name = 'build/build_output_create.html' - ajax_form_title = _('Create Build Output') - - def validate(self, build, form, **kwargs): - """ - Validation for the form: - """ - - quantity = form.cleaned_data.get('output_quantity', None) - serials = form.cleaned_data.get('serial_numbers', None) - - if quantity is not None: - build = self.get_object() - - # Check that requested output don't exceed build remaining quantity - maximum_output = int(build.remaining - build.incomplete_count) - - if quantity > maximum_output: - form.add_error( - 'output_quantity', - _('Maximum output quantity is ') + str(maximum_output), - ) - - elif quantity <= 0: - form.add_error( - 'output_quantity', - _('Output quantity must be greater than zero'), - ) - - # Check that the serial numbers are valid - if serials: - try: - extracted = extract_serial_numbers(serials, quantity, build.part.getLatestSerialNumberInt()) - - if extracted: - # Check for conflicting serial numbers - conflicts = build.part.find_conflicting_serial_numbers(extracted) - - if len(conflicts) > 0: - msg = ",".join([str(c) for c in conflicts]) - form.add_error( - 'serial_numbers', - _('Serial numbers already exist') + ': ' + msg, - ) - - except ValidationError as e: - form.add_error('serial_numbers', e.messages) - - else: - # If no serial numbers are provided, should they be? - if build.part.trackable: - form.add_error('serial_numbers', _('Serial numbers required for trackable build output')) - - def save(self, build, form, **kwargs): - """ - Create a new build output - """ - - data = form.cleaned_data - - quantity = data.get('output_quantity', None) - batch = data.get('batch', None) - - serials = data.get('serial_numbers', None) - - if serials: - serial_numbers = extract_serial_numbers(serials, quantity, build.part.getLatestSerialNumberInt()) - else: - serial_numbers = None - - build.create_build_output( - quantity, - serials=serial_numbers, - batch=batch, - ) - - def get_initial(self): - - initials = super().get_initial() - - build = self.get_object() - - # Calculate the required quantity - quantity = max(0, build.remaining - build.incomplete_count) - initials['output_quantity'] = int(quantity) - - return initials - - def get_form(self): - - build = self.get_object() - part = build.part - - context = self.get_form_kwargs() - - # Pass the 'part' through to the form, - # so we can add the next serial number as a placeholder - context['build'] = build - - form = self.form_class(**context) - - # If the part is not trackable, hide the serial number input - if not part.trackable: - form.fields['serial_numbers'].widget = HiddenInput() - - return form - - class BuildDetail(InvenTreeRoleMixin, DetailView): """ Detail view of a single Build object. diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index d186a9b5e7..b312937e30 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1498,6 +1498,16 @@ class Part(MPTTModel): def has_bom(self): return self.get_bom_items().count() > 0 + def get_trackable_parts(self): + """ + Return a queryset of all trackable parts in the BOM for this part + """ + + queryset = self.get_bom_items() + queryset = queryset.filter(sub_part__trackable=True) + + return queryset + @property def has_trackable_parts(self): """ @@ -1505,11 +1515,7 @@ class Part(MPTTModel): This is important when building the part. """ - for bom_item in self.get_bom_items().all(): - if bom_item.sub_part.trackable: - return True - - return False + return self.get_trackable_parts().count() > 0 @property def bom_count(self): diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 5782218780..d76fd366ff 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -21,6 +21,7 @@ /* exported allocateStockToBuild, completeBuildOrder, + createBuildOutput, editBuildOrder, loadAllocationTable, loadBuildOrderAllocationTable, @@ -175,6 +176,85 @@ function completeBuildOrder(build_id, options={}) { } +/* + * Construct a new build output against the provided build + */ +function createBuildOutput(build_id, options) { + + // Request build order information from the server + inventreeGet( + `/api/build/${build_id}/`, + {}, + { + success: function(build) { + + var html = ''; + + var trackable = build.part_detail.trackable; + var remaining = Math.max(0, build.quantity - build.completed); + + var fields = { + quantity: { + value: remaining, + }, + serial_numbers: { + hidden: !trackable, + required: options.trackable_parts || trackable, + }, + batch_code: {}, + auto_allocate: { + hidden: !trackable, + }, + }; + + // Work out the next available serial numbers + inventreeGet(`/api/part/${build.part}/serial-numbers/`, {}, { + success: function(data) { + if (data.next) { + fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`; + } else { + fields.serial_numbers.placeholder = `{% trans "Latest serial number" %}: ${data.latest}`; + } + }, + async: false, + }); + + if (options.trackable_parts) { + html += ` +
+ {% trans "The Bill of Materials contains trackable parts" %}.
+ {% trans "Build outputs must be generated individually" %}. +
+ `; + } + + if (trackable) { + html += ` +
+ {% trans "Trackable parts can have serial numbers specified" %}
+ {% trans "Enter serial numbers to generate multiple single build outputs" %} +
+ `; + } + + constructForm(`/api/build/${build_id}/create-output/`, { + method: 'POST', + title: '{% trans "Create Build Output" %}', + confirm: true, + fields: fields, + preFormContent: html, + onSuccess: function(response) { + location.reload(); + }, + }); + + } + } + ); + +} + + /* * Construct a set of output buttons for a particular build output */ diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index a93ceb42c7..df78c939cf 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -2014,7 +2014,7 @@ function constructField(name, parameters, options) { if (parameters.help_text && !options.hideLabels) { // Boolean values are handled differently! - if (parameters.type != 'boolean') { + if (parameters.type != 'boolean' && !parameters.hidden) { html += constructHelpText(name, parameters, options); } } @@ -2022,7 +2022,6 @@ function constructField(name, parameters, options) { // Div for error messages html += `
`; - html += ``; // controls html += ``; // form-group @@ -2212,6 +2211,10 @@ function constructInputOptions(name, classes, type, parameters, options={}) { return ``; } else if (parameters.type == 'boolean') { + if (parameters.hidden) { + return ''; + } + var help_text = ''; if (!options.hideLabels && parameters.help_text) {