mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +00:00 
			
		
		
		
	Merge pull request #2631 from SchrodingersGat/build-serial-number-magic
Build serial number magic
This commit is contained in:
		@@ -232,6 +232,29 @@ class BuildUnallocate(generics.CreateAPIView):
 | 
				
			|||||||
        return ctx
 | 
					        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):
 | 
					class BuildOutputComplete(generics.CreateAPIView):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    API endpoint for completing build outputs
 | 
					    API endpoint for completing build outputs
 | 
				
			||||||
@@ -455,6 +478,7 @@ build_api_urls = [
 | 
				
			|||||||
    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'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
 | 
					        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'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
 | 
				
			||||||
        url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
 | 
					        url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
 | 
				
			||||||
        url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
 | 
					        url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,51 +14,6 @@ from InvenTree.forms import HelperForm
 | 
				
			|||||||
from .models import Build
 | 
					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):
 | 
					class CancelBuildForm(HelperForm):
 | 
				
			||||||
    """ Form for cancelling a build """
 | 
					    """ Form for cancelling a build """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -646,11 +646,13 @@ class Build(MPTTModel, ReferenceIndexingMixin):
 | 
				
			|||||||
            batch: Override batch code
 | 
					            batch: Override batch code
 | 
				
			||||||
            serials: Serial numbers
 | 
					            serials: Serial numbers
 | 
				
			||||||
            location: Override location
 | 
					            location: Override location
 | 
				
			||||||
 | 
					            auto_allocate: Automatically allocate stock with matching serial numbers
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        batch = kwargs.get('batch', self.batch)
 | 
					        batch = kwargs.get('batch', self.batch)
 | 
				
			||||||
        location = kwargs.get('location', self.destination)
 | 
					        location = kwargs.get('location', self.destination)
 | 
				
			||||||
        serials = kwargs.get('serials', None)
 | 
					        serials = kwargs.get('serials', None)
 | 
				
			||||||
 | 
					        auto_allocate = kwargs.get('auto_allocate', False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        Determine if we can create a single output (with quantity > 0),
 | 
					        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
 | 
					            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):
 | 
					            for ii in range(quantity):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if serials:
 | 
					                if serials:
 | 
				
			||||||
@@ -679,7 +684,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
 | 
				
			|||||||
                else:
 | 
					                else:
 | 
				
			||||||
                    serial = None
 | 
					                    serial = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                StockModels.StockItem.objects.create(
 | 
					                output = StockModels.StockItem.objects.create(
 | 
				
			||||||
                    quantity=1,
 | 
					                    quantity=1,
 | 
				
			||||||
                    location=location,
 | 
					                    location=location,
 | 
				
			||||||
                    part=self.part,
 | 
					                    part=self.part,
 | 
				
			||||||
@@ -689,6 +694,37 @@ class Build(MPTTModel, ReferenceIndexingMixin):
 | 
				
			|||||||
                    is_building=True,
 | 
					                    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:
 | 
					        else:
 | 
				
			||||||
            """
 | 
					            """
 | 
				
			||||||
            Create a single build output of the given quantity
 | 
					            Create a single build output of the given quantity
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,6 +19,7 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS
 | 
				
			|||||||
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
 | 
					from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import InvenTree.helpers
 | 
					import InvenTree.helpers
 | 
				
			||||||
 | 
					from InvenTree.helpers import extract_serial_numbers
 | 
				
			||||||
from InvenTree.serializers import InvenTreeDecimalField
 | 
					from InvenTree.serializers import InvenTreeDecimalField
 | 
				
			||||||
from InvenTree.status_codes import StockStatus
 | 
					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/<pk>/create-output/", where <pk> 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):
 | 
					class BuildOutputDeleteSerializer(serializers.Serializer):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    DRF serializer for deleting (cancelling) one or more build outputs
 | 
					    DRF serializer for deleting (cancelling) one or more build outputs
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,20 +0,0 @@
 | 
				
			|||||||
{% extends "modal_form.html" %}
 | 
					 | 
				
			||||||
{% load i18n %}
 | 
					 | 
				
			||||||
{% block pre_form_content %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% if build.part.has_trackable_parts %}
 | 
					 | 
				
			||||||
<div class='alert alert-block alert-warning'>
 | 
					 | 
				
			||||||
    {% trans "The Bill of Materials contains trackable parts" %}<br>
 | 
					 | 
				
			||||||
    {% trans "Build outputs must be generated individually." %}<br>
 | 
					 | 
				
			||||||
    {% trans "Multiple build outputs will be created based on the quantity specified." %}
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
{% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% if build.part.trackable %}
 | 
					 | 
				
			||||||
<div class='alert alert-block alert-info'>
 | 
					 | 
				
			||||||
    {% trans "Trackable parts can have serial numbers specified" %}<br>
 | 
					 | 
				
			||||||
    {% trans "Enter serial numbers to generate multiple single build outputs" %}
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
{% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					 | 
				
			||||||
@@ -321,9 +321,11 @@
 | 
				
			|||||||
{{ block.super }}
 | 
					{{ block.super }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
$('#btn-create-output').click(function() {
 | 
					$('#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%},
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,7 +9,6 @@ from . import views
 | 
				
			|||||||
build_detail_urls = [
 | 
					build_detail_urls = [
 | 
				
			||||||
    url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
 | 
					    url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
 | 
				
			||||||
    url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
 | 
					    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'),
 | 
					    url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,16 +6,14 @@ Django views for interacting with Build objects
 | 
				
			|||||||
from __future__ import unicode_literals
 | 
					from __future__ import unicode_literals
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.utils.translation import ugettext_lazy as _
 | 
					from django.utils.translation import ugettext_lazy as _
 | 
				
			||||||
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 .models import Build
 | 
					from .models import Build
 | 
				
			||||||
from . import forms
 | 
					from . import forms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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
 | 
					from InvenTree.helpers import str2bool
 | 
				
			||||||
from InvenTree.status_codes import BuildStatus
 | 
					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):
 | 
					class BuildDetail(InvenTreeRoleMixin, DetailView):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Detail view of a single Build object.
 | 
					    Detail view of a single Build object.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1498,6 +1498,16 @@ class Part(MPTTModel):
 | 
				
			|||||||
    def has_bom(self):
 | 
					    def has_bom(self):
 | 
				
			||||||
        return self.get_bom_items().count() > 0
 | 
					        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
 | 
					    @property
 | 
				
			||||||
    def has_trackable_parts(self):
 | 
					    def has_trackable_parts(self):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
@@ -1505,11 +1515,7 @@ class Part(MPTTModel):
 | 
				
			|||||||
        This is important when building the part.
 | 
					        This is important when building the part.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for bom_item in self.get_bom_items().all():
 | 
					        return self.get_trackable_parts().count() > 0
 | 
				
			||||||
            if bom_item.sub_part.trackable:
 | 
					 | 
				
			||||||
                return True
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def bom_count(self):
 | 
					    def bom_count(self):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,6 +21,7 @@
 | 
				
			|||||||
/* exported
 | 
					/* exported
 | 
				
			||||||
    allocateStockToBuild,
 | 
					    allocateStockToBuild,
 | 
				
			||||||
    completeBuildOrder,
 | 
					    completeBuildOrder,
 | 
				
			||||||
 | 
					    createBuildOutput,
 | 
				
			||||||
    editBuildOrder,
 | 
					    editBuildOrder,
 | 
				
			||||||
    loadAllocationTable,
 | 
					    loadAllocationTable,
 | 
				
			||||||
    loadBuildOrderAllocationTable,
 | 
					    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 += `
 | 
				
			||||||
 | 
					                    <div class='alert alert-block alert-info'>
 | 
				
			||||||
 | 
					                        {% trans "The Bill of Materials contains trackable parts" %}.<br>
 | 
				
			||||||
 | 
					                        {% trans "Build outputs must be generated individually" %}.
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                    `;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (trackable) {
 | 
				
			||||||
 | 
					                    html += `
 | 
				
			||||||
 | 
					                    <div class='alert alert-block alert-info'>
 | 
				
			||||||
 | 
					                        {% trans "Trackable parts can have serial numbers specified" %}<br>
 | 
				
			||||||
 | 
					                        {% trans "Enter serial numbers to generate multiple single build outputs" %}
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                    `;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                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
 | 
					 * Construct a set of output buttons for a particular build output
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2014,7 +2014,7 @@ function constructField(name, parameters, options) {
 | 
				
			|||||||
    if (parameters.help_text && !options.hideLabels) {
 | 
					    if (parameters.help_text && !options.hideLabels) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Boolean values are handled differently!
 | 
					        // Boolean values are handled differently!
 | 
				
			||||||
        if (parameters.type != 'boolean') {
 | 
					        if (parameters.type != 'boolean' && !parameters.hidden) {
 | 
				
			||||||
            html += constructHelpText(name, parameters, options);
 | 
					            html += constructHelpText(name, parameters, options);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -2022,7 +2022,6 @@ function constructField(name, parameters, options) {
 | 
				
			|||||||
    // Div for error messages
 | 
					    // Div for error messages
 | 
				
			||||||
    html += `<div id='errors-${field_name}'></div>`;
 | 
					    html += `<div id='errors-${field_name}'></div>`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
    html += `</div>`; // controls
 | 
					    html += `</div>`; // controls
 | 
				
			||||||
    html += `</div>`; // form-group
 | 
					    html += `</div>`; // form-group
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
@@ -2212,6 +2211,10 @@ function constructInputOptions(name, classes, type, parameters, options={}) {
 | 
				
			|||||||
        return `<textarea ${opts.join(' ')}></textarea>`;
 | 
					        return `<textarea ${opts.join(' ')}></textarea>`;
 | 
				
			||||||
    } else if (parameters.type == 'boolean') {
 | 
					    } else if (parameters.type == 'boolean') {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (parameters.hidden) {
 | 
				
			||||||
 | 
					            return '';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var help_text = '';
 | 
					        var help_text = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!options.hideLabels && parameters.help_text) {
 | 
					        if (!options.hideLabels && parameters.help_text) {
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user