diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 99d8dc6500..0d90797467 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -6,13 +6,14 @@ Django Forms for interacting with Build objects from __future__ import unicode_literals from django.utils.translation import ugettext as _ +from django import forms from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField -from django import forms from .models import Build, BuildItem, BuildOrderAttachment -from stock.models import StockLocation + +from stock.models import StockLocation, StockItem class EditBuildForm(HelperForm): @@ -106,18 +107,18 @@ class UnallocateBuildForm(HelperForm): class AutoAllocateForm(HelperForm): """ Form for auto-allocation of stock to a build """ - confirm = forms.BooleanField(required=False, help_text=_('Confirm')) + confirm = forms.BooleanField(required=True, help_text=_('Confirm stock allocation')) - output_id = forms.IntegerField( - required=False, - widget=forms.HiddenInput() + # Keep track of which build output we are interested in + output = forms.ModelChoiceField( + queryset=StockItem.objects.all(), ) class Meta: model = Build fields = [ 'confirm', - 'output_id', + 'output', ] @@ -136,20 +137,25 @@ class CompleteBuildForm(HelperForm): help_text=_('Location of completed parts'), ) - serial_numbers = forms.CharField( - label=_('Serial numbers'), + confirm_incomplete = forms.BooleanField( required=False, - help_text=_('Enter unique serial numbers (or leave blank)') + help_text=_("Confirm completion with incomplete stock allocation") ) - confirm = forms.BooleanField(required=False, help_text=_('Confirm build completion')) + confirm = forms.BooleanField(required=True, help_text=_('Confirm build completion')) + + output = forms.ModelChoiceField( + queryset=StockItem.objects.all(), # Queryset is narrowed in the view + widget=forms.HiddenInput(), + ) class Meta: model = Build fields = [ - 'serial_numbers', 'location', - 'confirm' + 'output', + 'confirm', + 'confirm_incomplete', ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index d213431039..03997e2353 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -24,7 +24,6 @@ from mptt.models import MPTTModel, TreeForeignKey from InvenTree.status_codes import BuildStatus from InvenTree.helpers import increment, getSetting, normalize -from InvenTree.helpers import ExtractSerialNumbers from InvenTree.validators import validate_build_order_reference from InvenTree.models import InvenTreeAttachment @@ -183,6 +182,16 @@ class Build(MPTTModel): blank=True, help_text=_('Extra build notes') ) + @property + def bom_items(self): + """ + Returns the BOM items for the part referenced by this BuildOrder + """ + + return self.part.bom_items.all().prefetch_related( + 'sub_part' + ) + @property def remaining(self): """ @@ -195,9 +204,13 @@ class Build(MPTTModel): def output_count(self): return self.build_outputs.count() - def getBuildOutputs(self, **kwargs): + def get_build_outputs(self, **kwargs): """ - Return a list of build outputs + Return a list of build outputs. + + kwargs: + complete = (True / False) - If supplied, filter by completed status + in_stock = (True / False) - If supplied, filter by 'in-stock' status """ outputs = self.build_outputs @@ -228,7 +241,7 @@ class Build(MPTTModel): Return all the "completed" build outputs """ - outputs = self.getBuildOutputs(complete=True) + outputs = self.get_build_outputs(complete=True) # TODO - Ordering? @@ -240,7 +253,7 @@ class Build(MPTTModel): Return all the "incomplete" build outputs" """ - outputs = self.getBuildOutputs(complete=False) + outputs = self.get_build_outputs(complete=False) # TODO - Order by how "complete" they are? @@ -278,49 +291,6 @@ class Build(MPTTModel): return new_ref - def createInitialStockItem(self, serial_numbers, user): - """ - Create an initial output StockItem to be completed by this build. - """ - - if self.part.trackable: - # Trackable part? Create individual build outputs? - - # Serial numbers specified for the build? - if serial_numbers: - serials = ExtractSerialNumbers(serial_numbers, self.quantity) - else: - serials = [None] * self.quantity - - for serial in serials: - output = StockModels.StockItem.objects.create( - part=self.part, - location=self.destination, - quantity=1, - batch=self.batch, - serial=serial, - build=self, - is_building=True - ) - - output.save() - - else: - # Create a single build output - - output = StockModels.StockItem.objects.create( - part=self.part, # Link to the parent part - location=None, # No location (yet) until it is completed - quantity=self.quantity, - batch='', # The 'batch' code is not set until the item is completed - build=self, # Point back to this build - is_building=True, # Mark this StockItem as building - ) - - output.save() - - # TODO - Add a transaction note to the new StockItem - @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED @@ -368,59 +338,49 @@ class Build(MPTTModel): Iterate through each item in the BOM """ - # Only look at the "untracked" BOM items - # Tracked BOM items must be handled separately - untracked_bom_items = self.part.bom_items.filter(sub_part__trackable=False) + for bom_item in self.bom_items: - for item in untracked_bom_items.prefetch_related('sub_part'): + part = bom_item.sub_part - # How many parts are still required for this build? - #q_required = item.quantity * self.remaining - q_required = self.getUnallocatedQuantity(item.sub_part) + # Skip any parts which are already fully allocated + if self.isPartFullyAllocated(part, output): + continue - # Grab a list of StockItem objects which are "in stock" - stock = StockModels.StockItem.objects.filter( - StockModels.StockItem.IN_STOCK_FILTER - ) - - # Filter by part reference - stock = stock.filter(part=item.sub_part) + # How many parts are required to complete the output? + required = self.unallocatedQuantity(part, output) - # Exclude any stock items which have already been allocated to this build - allocated = BuildItem.objects.filter( - build=self, - stock_item__part=item.sub_part - ) - - stock = stock.exclude( - pk__in=[build_item.stock_item.pk for build_item in allocated] - ) + # Grab a list of stock items which are available + stock_items = self.availableStockItems(part, output) # Ensure that the available stock items are in the correct location if self.take_from is not None: # Filter for stock that is located downstream of the designated location - stock = stock.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()]) + stock_items = stock_items.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()]) # Only one StockItem to choose from? Default to that one! - if stock.count() == 1: - stock_item = stock[0] + if stock_items.count() == 1: + stock_item = stock_items[0] - # Check that we have not already allocated this stock-item against this build - build_items = BuildItem.objects.filter(build=self, stock_item=stock_item) + # Double check that we have not already allocated this stock-item against this build + build_items = BuildItem.objects.filter( + build=self, + stock_item=stock_item, + install_into=output + ) if len(build_items) > 0: continue - # Are there any parts available? + # How many items are actually available? if stock_item.quantity > 0: # Only take as many as are available - if stock_item.quantity < q_required: - q_required = stock_item.quantity + if stock_item.quantity < required: + required = stock_item.quantity allocation = { 'stock_item': stock_item, - 'quantity': q_required, + 'quantity': required, } allocations.append(allocation) @@ -472,7 +432,7 @@ class Build(MPTTModel): output.delete() @transaction.atomic - def auto_allocate(self, output=None): + def autoAllocate(self, output): """ Run auto-allocation routine to allocate StockItems to this Build. @@ -496,151 +456,160 @@ class Build(MPTTModel): build_item = BuildItem( build=self, stock_item=item['stock_item'], - quantity=item['quantity']) + quantity=item['quantity'], + install_into=output, + ) build_item.save() @transaction.atomic - def completeBuild(self, location, serial_numbers, user): - """ Mark the Build as COMPLETE + def completeBuildOutput(self, output, user, **kwargs): + """ + Complete a particular build output - - Takes allocated items from stock - - Delete pending BuildItem objects + - Remove allocated StockItems + - Mark the output as complete """ - # Complete the build allocation for each BuildItem - for build_item in self.allocated_stock.all().prefetch_related('stock_item'): - build_item.complete_allocation(user) + # List the allocated BuildItem objects for the given output + allocated_items = output.items_to_install.all() - # Check that the stock-item has been assigned to this build, and remove the builditem from the database - if build_item.stock_item.build_order == self: - build_item.delete() + for build_item in allocated_items: - notes = 'Built {q} on {now}'.format( - q=self.quantity, - now=str(datetime.now().date()) + # Complete the allocation of stock for that item + build_item.completeAllocation(user) + + # Remove the build item from the database + build_item.delete() + + # Ensure that the output is updated correctly + output.build = self + output.is_building = False + + output.save() + + output.addTransactionNote( + _('Completed build output'), + user, + system=True ) - # Generate the build outputs - if self.part.trackable and serial_numbers: - # Add new serial numbers - for serial in serial_numbers: - item = StockModels.StockItem.objects.create( - part=self.part, - build=self, - location=location, - quantity=1, - serial=serial, - batch=str(self.batch) if self.batch else '', - notes=notes - ) - - item.save() - - else: - # Add stock of the newly created item - item = StockModels.StockItem.objects.create( - part=self.part, - build=self, - location=location, - quantity=self.quantity, - batch=str(self.batch) if self.batch else '', - notes=notes - ) - - item.save() - - # Finally, mark the build as complete - self.completion_date = datetime.now().date() - self.completed_by = user - self.status = BuildStatus.COMPLETE + # Increase the completed quantity for this build + self.completed += output.quantity self.save() - return True - - def isFullyAllocated(self): + def requiredQuantity(self, part, output): """ - Return True if this build has been fully allocated. - """ - - bom_items = self.part.bom_items.all() - - for item in bom_items: - part = item.sub_part - - if not self.isPartFullyAllocated(part): - return False - - return True - - def isPartFullyAllocated(self, part): - """ - Check if a given Part is fully allocated for this Build - """ - - return self.getAllocatedQuantity(part) >= self.getRequiredQuantity(part) - - def getRequiredQuantity(self, part, output=None): - """ - Calculate the quantity of required to make this build. + Get the quantity of a part required to complete the particular build output. Args: - part: The 'Part' archetype reference - output: A particular build output (StockItem) (or None to specify the entire build) + part: The Part object + output - The particular build output (StockItem) """ + # Extract the BOM line item from the database try: - item = PartModels.BomItem.objects.get(part=self.part.id, sub_part=part.id) - q = item.quantity - except PartModels.BomItem.DoesNotExist: - q = 0 + bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk) + quantity = bom_item.quantity + except (PartModels.BomItem.DoesNotExist): + quantity = 0 if output: - return q * output.quantity + quantity *= output.quantity else: - return q * self.remaining + quantity *= self.remaining - def getAllocatedQuantity(self, part, output=None): + return quantity + + def allocatedItems(self, part, output): """ - Calculate the total number of currently allocated to this build. + Return all BuildItem objects which allocate stock of to Args: - part: The 'Part' archetype reference - output: A particular build output (StockItem) (or None to specify the entire build) + part - The part object + output - Build output (StockItem). """ allocations = BuildItem.objects.filter( - build=self.id, - stock_item__part=part.id + build=self, + stock_item__part=part, + install_into=output, ) - # Optionally, filter by the specified build output StockItem - if output is not None: - allocations = allocations.filter( - install_into=output - ) + return allocations + + def allocatedQuantity(self, part, output): + """ + Return the total quantity of given part allocated to a given build output. + """ + + allocations = self.allocatedItems(part, output) allocated = allocations.aggregate(q=Coalesce(Sum('quantity'), 0)) return allocated['q'] - def getUnallocatedQuantity(self, part, output=None): + def unallocatedQuantity(self, part, output): """ - Calculate the quantity of which still needs to be allocated to this build. - - Args: - part - the part to be tested - output - A particular build output (StockItem) (or None to specify the entire build) - - Returns: - The remaining allocated quantity + Return the total unallocated (remaining) quantity of a part against a particular output. """ - required = self.getRequiredQuantity(part, output=output) - allocated = self.getAllocatedQuantity(part, output=output) + required = self.requiredQuantity(part, output) + allocated = self.allocatedQuantity(part, output) return max(required - allocated, 0) + def isPartFullyAllocated(self, part, output): + """ + Returns True if the part has been fully allocated to the particular build output + """ + + return self.unallocatedQuantity(part, output) == 0 + + def isFullyAllocated(self, output): + """ + Returns True if the particular build output is fully allocated. + """ + + for bom_item in self.bom_items: + part = bom_item.sub_part + + if not self.isPartFullyAllocated(part, output): + return False + + # All parts must be fully allocated! + return True + + def allocatedParts(self, output): + """ + Return a list of parts which have been fully allocated against a particular output + """ + + allocated = [] + + for bom_item in self.bom_items: + part = bom_item.sub_part + + if self.isPartFullyAllocated(part, output): + allocated.append(part) + + return allocated + + def unallocatedParts(self, output): + """ + Return a list of parts which have *not* been fully allocated against a particular output + """ + + unallocated = [] + + for bom_item in self.bom_items: + part = bom_item.sub_part + + if not self.isPartFullyAllocated(part, output): + unallocated.append(part) + + return unallocated + @property def required_parts(self): """ Returns a dict of parts required to build this part (BOM) """ @@ -658,29 +627,32 @@ class Build(MPTTModel): return parts - def getAvailableStockItems(self, part=None, output=None): + def availableStockItems(self, part, output): """ - Return available stock items for the build. + Returns stock items which are available for allocation to this build. + + Args: + part - Part object + output - The particular build output """ - items = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER) + # Grab initial query for items which are "in stock" and match the part + items = StockModels.StockItem.objects.filter( + StockModels.StockItem.IN_STOCK_FILTER + ) - if part: - # Filter items which match the given Part - items = items.filter(part=part) + items = items.filter(part=part) - if output: - # Exclude items which are already allocated to the particular build output + # Exclude any items which have already been allocated + allocated = BuildItem.objects.filter( + build=self, + stock_item__part=part, + install_into=output, + ) - to_exclude = BuildItem.objects.filter( - build=self, - stock_item__part=part, - install_into=output - ) - - items = items.exclude( - id__in=[item.stock_item.id for item in to_exclude.all()] - ) + items = items.exclude( + id__in=[item.stock_item.id for item in allocated.all()] + ) # Limit query to stock items which are "downstream" of the source location if self.take_from is not None: @@ -690,16 +662,6 @@ class Build(MPTTModel): return items - @property - def can_build(self): - """ Return true if there are enough parts to supply build """ - - for item in self.required_parts: - if item['part'].total_stock < item['quantity']: - return False - - return True - @property def is_active(self): """ Is this build active? An active build is either: @@ -807,31 +769,23 @@ class BuildItem(models.Model): errors['quantity'] = _('Allocation quantity must be greater than zero') # Quantity must be 1 for serialized stock - if self.stock_item.serial and not self.quantity == 1: + if self.stock_item.serialized and not self.quantity == 1: errors['quantity'] = _('Quantity must be 1 for serialized stock') - # Part reference must match between output stock item and built part - if self.install_into is not None: - if not self.install_into.part == self.build.part: - errors['install_into'] = _('Part reference differs between build and build output') - - # A trackable StockItem *must* point to a build output - if self.stock_item.part.trackable and self.install_into is None: - errors['install_into'] = _('Trackable BuildItem must reference a build output') - - # A non-trackable StockItem *must not* point to a build output - if not self.stock_item.part.trackable and self.install_into is not None: - errors['install_into'] = _('Non-trackable BuildItem must not reference a build output') - except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist): pass if len(errors) > 0: raise ValidationError(errors) - def complete_allocation(self, user): + @transaction.atomic + def completeAllocation(self, user): + """ + Complete the allocation of this BuildItem into the output stock item. - # TODO : This required much reworking!! + - If the referenced part is trackable, the stock item will be *installed* into the build output + - If the referenced part is *not* trackable, the stock item will be removed from stock + """ item = self.stock_item @@ -843,10 +797,13 @@ class BuildItem(models.Model): self.stock_item = item self.save() - # TODO - If the item__part object is not trackable, delete the stock item here - - item.build_order = self.build - item.save() + if item.part.trackable: + # If the part is trackable, install into the build output + item.belongs_to = self.install_into + item.save() + else: + # Part is *not* trackable, so just delete it + item.delete() build = models.ForeignKey( Build, diff --git a/InvenTree/build/templates/build/allocate.html b/InvenTree/build/templates/build/allocate.html index 49a932c8aa..bae06c2c16 100644 --- a/InvenTree/build/templates/build/allocate.html +++ b/InvenTree/build/templates/build/allocate.html @@ -11,6 +11,9 @@ InvenTree | Allocate Parts {% include "build/tabs.html" with tab='allocate' %} +
+ +

{% trans "Incomplete Build Ouputs" %}

@@ -19,20 +22,17 @@ InvenTree | Allocate Parts {% endfor %}
-
-

{% trans "Assigned Stock" %}

- -
- {% if build.status == BuildStatus.PENDING %} -
- - - + {% endblock %} @@ -46,14 +46,6 @@ InvenTree | Allocate Parts part: {{ build.part.pk }}, }; - loadBuildOutputAllocationTable( - buildInfo, - null, - { - table: '#build-item-list', - } - ); - {% for item in build.incomplete_outputs %} // Get the build output as a javascript object inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, diff --git a/InvenTree/build/templates/build/auto_allocate.html b/InvenTree/build/templates/build/auto_allocate.html index 1455cc3525..48d1837ae0 100644 --- a/InvenTree/build/templates/build/auto_allocate.html +++ b/InvenTree/build/templates/build/auto_allocate.html @@ -6,19 +6,10 @@ {{ block.super }}
-{% trans "Automatically Allocate Stock" %}
-{% trans "Where the following conditions are met, stock will be automatically allocated to this build" %}:
-
-{% trans "For each part in the BOM, the following tests are performed" %}:
-
    -
  • {% trans "The part is not marked as trackable" %}
  • -
  • {% trans "Only single stock items exists" %}
  • -
  • {% trans "The stock item is not already allocated to this build" %}
  • -
+ {% trans "Automatically Allocate Stock" %}
+ {% trans "The following stock items will be allocated to the specified build output" %}
- {% if allocations %} - diff --git a/InvenTree/build/templates/build/complete.html b/InvenTree/build/templates/build/complete.html index a48b831645..54c3fc6763 100644 --- a/InvenTree/build/templates/build/complete.html +++ b/InvenTree/build/templates/build/complete.html @@ -1,36 +1,47 @@ {% extends "modal_form.html" %} +{% load inventree_extras %} {% load i18n %} {% block pre_form_content %} -

{% trans "Build" %} - {{ build }}

- -{% if build.isFullyAllocated %} +{% if fully_allocated %}
-

{% trans "Build order allocation is complete" %}

+

{% trans "Stock allocation is complete" %}

{% else %}
-

{% trans "Warning: Build order allocation is not complete" %}

- {% trans "Build Order has not been fully allocated. Ensure that all Stock Items have been allocated to the Build" %} +

{% trans "Stock allocation is incomplete" %}

+ +
+
+ +
+
+
    + {% for part in unallocated_parts %} +
  • + {% include "hover_image.html" with image=part.image %} {{ part }} +
  • + {% endfor %} +
+
+
+
+
{% endif %} -
-

{% trans "The following actions will be performed:" %}

-
    -
  • {% trans "Remove allocated items from stock" %}
  • -
  • {% trans "Add completed items to stock" %}
  • -
-
- -
+
{% trans "The following items will be created" %}
{% include "hover_image.html" with image=build.part.image hover=True %} - {{ build.quantity }} x {{ build.part.full_name }} + {% decimal output.quantity %} x {{ output.part.full_name }}
diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index b51d12f772..68a842755d 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -114,19 +114,6 @@ {% endif %}
- {% if build.is_active %} - - - - - - {% endif %} {% if build.completion_date %} diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 1d7a45fcf0..37d877a905 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -82,12 +82,10 @@ class BuildTest(TestCase): self.assertEqual(self.build.getRequiredQuantity(self.sub_part_1), 100) self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250) - self.assertTrue(self.build.can_build) self.assertFalse(self.build.is_complete) # Delete some stock and see if the build can still be completed self.stock_2_1.delete() - self.assertFalse(self.build.can_build) def test_build_item_clean(self): # Ensure that dodgy BuildItem objects cannot be created diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 57df745d6d..4716fd7583 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -95,6 +95,9 @@ class BuildAutoAllocate(AjaxUpdateView): role_required = 'build.change' def get_initial(self): + """ + Initial values for the form. + """ initials = super().get_initial() @@ -102,62 +105,73 @@ class BuildAutoAllocate(AjaxUpdateView): output = self.get_param('output') if output: - initials['output_id'] = output + try: + output = StockItem.objects.get(pk=output) + initials['output'] = output + except (ValueError, StockItem.DoesNotExist): + pass return initials def get_context_data(self, *args, **kwargs): - """ Get the context data for form rendering. """ + """ + Get the context data for form rendering. + """ context = {} - output = self.get_form()['output_id'].value() + build = self.get_object() + + form = self.get_form() + + output_id = form['output'].value() try: - build = Build.objects.get(id=self.kwargs['pk']) - context['build'] = build + output = StockItem.objects.get(pk=output_id) + except (ValueError, StockItem.DoesNotExist): + output = None + + if output: + context['output'] = output context['allocations'] = build.getAutoAllocations(output) - except Build.DoesNotExist: - context['error'] = _('No matching build found') + + context['build'] = build return context - def post(self, request, *args, **kwargs): - """ Handle POST request. Perform auto allocations. + def get_form(self): - - If the form validation passes, perform allocations - - Otherwise, the form is passed back to the client + form = super().get_form() + + if form['output'].value(): + # Hide the 'output' field + form.fields['output'].widget = HiddenInput() + + return form + + def validate(self, build, form, **kwargs): + + output = form.cleaned_data.get('output', None) + + if not output: + form.add_error(None, _('Build output must be specified')) + + def post_save(self, build, form, **kwargs): + """ + Once the form has been validated, + perform auto-allocations """ build = self.get_object() - form = self.get_form() + output = form.cleaned_data.get('output', None) - confirm = request.POST.get('confirm', False) + build.autoAllocate(output) - output = None - output_id = request.POST.get('output_id', None) - - if output_id: - try: - output = StockItem.objects.get(pk=output_id) - except (ValueError, StockItem.DoesNotExist): - pass - - valid = False - - if confirm: - build.auto_allocate(output) - valid = True - else: - form.add_error('confirm', _('Confirm stock allocation')) - form.add_error(None, _('Check the confirmation box at the bottom of the list')) - - data = { - 'form_valid': valid, + def get_data(self): + return { + 'success': _('Allocated stock to build output'), } - return self.renderJsonResponse(request, form, data, context=self.get_context_data()) - class BuildOutputDelete(AjaxUpdateView): """ @@ -292,37 +306,60 @@ class BuildComplete(AjaxUpdateView): model = Build form_class = forms.CompleteBuildForm context_object_name = "build" - ajax_form_title = _("Complete Build") + ajax_form_title = _("Complete Build Output") ajax_template_name = "build/complete.html" role_required = 'build.change' def get_form(self): - """ Get the form object. - - If the part is trackable, include a field for serial numbers. - """ + build = self.get_object() form = super().get_form() - if not build.part.trackable: - form.fields.pop('serial_numbers') - else: + # Extract the build output object + output = None + output_id = form['output'].value() - form.field_placeholder['serial_numbers'] = build.part.getSerialNumberString(build.quantity) + try: + output = StockItem.objects.get(pk=output_id) + except (ValueError, StockItem.DoesNotExist): + pass - form.rebuild_layout() + if output: + if build.isFullyAllocated(output): + form.fields['confirm_incomplete'].widget = HiddenInput() return form + def validate(self, build, form, **kwargs): + + data = form.cleaned_data + + output = data.get('output', None) + + if output: + + quantity = data.get('quantity', None) + + if quantity and quantity > output.quantity: + form.add_error('quantity', _('Quantity to complete cannot exceed build output quantity')) + + if not build.isFullyAllocated(output): + confirm = str2bool(data.get('confirm_incomplete', False)) + + if not confirm: + form.add_error('confirm_incomplete', _('Confirm completion of incomplete build')) + + else: + form.add_error(None, _('Build output must be specified')) + def get_initial(self): """ Get initial form data for the CompleteBuild form - If the part being built has a default location, pre-select that location """ - initials = super(BuildComplete, self).get_initial().copy() - + initials = super().get_initial() build = self.get_object() if build.part.default_location is not None: @@ -332,94 +369,77 @@ class BuildComplete(AjaxUpdateView): except StockLocation.DoesNotExist: pass + output = self.get_param('output', None) + + if output: + try: + output = StockItem.objects.get(pk=output) + except (ValueError, StockItem.DoesNotExist): + output = None + + # Output has not been supplied? Try to "guess" + if not output: + + incomplete = build.get_build_outputs(complete=False) + + if incomplete.count() == 1: + output = incomplete[0] + + if output is not None: + initials['output'] = output + + initials['location'] = build.destination + return initials def get_context_data(self, **kwargs): - """ Get context data for passing to the rendered form + """ + Get context data for passing to the rendered form - Build information is required """ - build = Build.objects.get(id=self.kwargs['pk']) + build = self.get_object() context = {} # Build object context['build'] = build - # Items to be removed from stock - taking = BuildItem.objects.filter(build=build.id) - context['taking'] = taking - - return context - - def post(self, request, *args, **kwargs): - """ Handle POST request. Mark the build as COMPLETE - - - If the form validation passes, the Build objects completeBuild() method is called - - Otherwise, the form is passed back to the client - """ - - build = self.get_object() - form = self.get_form() - confirm = str2bool(request.POST.get('confirm', False)) + output = form['output'].value() - loc_id = request.POST.get('location', None) - - valid = False - - if confirm is False: - form.add_error('confirm', _('Confirm completion of build')) - else: + if output: try: - location = StockLocation.objects.get(id=loc_id) - valid = True - except (ValueError, StockLocation.DoesNotExist): - form.add_error('location', _('Invalid location selected')) + output = StockItem.objects.get(pk=output) + context['output'] = output + context['fully_allocated'] = build.isFullyAllocated(output) + context['allocated_parts'] = build.allocatedParts(output) + context['unallocated_parts'] = build.unallocatedParts(output) + except (ValueError, StockItem.DoesNotExist): + pass - serials = [] + return context - if build.part.trackable: - # A build for a trackable part may optionally specify serial numbers. + def post_save(self, build, form, **kwargs): - sn = request.POST.get('serial_numbers', '') + data = form.cleaned_data - sn = str(sn).strip() - - # If the user has specified serial numbers, check they are valid - if len(sn) > 0: - try: - # Exctract a list of provided serial numbers - serials = ExtractSerialNumbers(sn, build.quantity) - - existing = build.part.find_conflicting_serial_numbers(serials) - - if len(existing) > 0: - exists = ",".join([str(x) for x in existing]) - form.add_error('serial_numbers', _('The following serial numbers already exist: ({sn})'.format(sn=exists))) - valid = False - - except ValidationError as e: - form.add_error('serial_numbers', e.messages) - valid = False - - if valid: - if not build.completeBuild(location, serials, request.user): - form.add_error(None, _('Build could not be completed')) - valid = False - - data = { - 'form_valid': valid, - } - - return self.renderJsonResponse(request, form, data, context=self.get_context_data()) + location = data.get('location', None) + output = data.get('output', None) + # Complete the build output + build.completeBuildOutput( + output, + self.request.user, + location=location, + ) + def get_data(self): """ Provide feedback data back to the form """ return { - 'info': _('Build marked as COMPLETE') + 'success': _('Build output completed') } @@ -588,19 +608,6 @@ class BuildCreate(AjaxCreateView): 'serial_numbers', _('Serial numbers already exist') + ': ' + msg ) - - def post_save(self, **kwargs): - """ - Called immediately after a new Build object is created. - """ - - build = kwargs['new_object'] - request = kwargs['request'] - data = kwargs['data'] - - serials = data['serial_numbers'] - - build.createInitialStockItem(serials, request.user) class BuildUpdate(AjaxUpdateView): @@ -684,11 +691,13 @@ class BuildItemCreate(AjaxCreateView): return ctx - def validate(self, request, form, data): + def validate(self, build_item, form, **kwargs): """ Extra validation steps as required """ + data = form.cleaned_data + stock_item = data.get('stock_item', None) quantity = data.get('quantity', None) @@ -702,7 +711,9 @@ class BuildItemCreate(AjaxCreateView): available = stock_item.unallocated_quantity() if quantity > available: form.add_error('stock_item', _('Stock item is over-allocated')) - form.add_error('quantity', _('Avaialabe') + ': ' + str(normalize(available))) + form.add_error('quantity', _('Available') + ': ' + str(normalize(available))) + else: + form.add_error('stock_item', _('Stock item must be selected')) def get_form(self): """ Create Form for making / editing new Part object """ @@ -758,7 +769,7 @@ class BuildItemCreate(AjaxCreateView): form.fields['install_into'].widget = HiddenInput() if self.build and self.part: - available_items = self.build.getAvailableStockItems(part=self.part, output=self.output) + available_items = self.build.availableStockItems(self.part, self.output) form.fields['stock_item'].queryset = available_items self.available_stock = form.fields['stock_item'].queryset.all() @@ -819,7 +830,7 @@ class BuildItemCreate(AjaxCreateView): # Work out how much stock is required if build and part: - required_quantity = build.getUnallocatedQuantity(part, output=output) + required_quantity = build.unallocatedQuantity(part, output) else: required_quantity = None diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index f2d04f40cb..4544a09b66 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -905,6 +905,18 @@ class Part(MPTTModel): def has_bom(self): return self.bom_count > 0 + def has_trackable_parts(self): + """ + Return True if any parts linked in the Bill of Materials are trackable. + This is important when building the part. + """ + + for bom_item in self.bom_items.all(): + if bom_item.sub_part.trackable: + return True + + return False + @property def bom_count(self): """ Return the number of items contained in the BOM for this part """ @@ -1188,7 +1200,7 @@ class Part(MPTTModel): parameter.save() @transaction.atomic - def deepCopy(self, other, **kwargs): + def deep_copy(self, other, **kwargs): """ Duplicates non-field data from another part. Does not alter the normal fields of this part, but can be used to copy other data linked by ForeignKey refernce. diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 97330f1ced..1301df3c91 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -99,7 +99,7 @@ class PartTest(TestCase): self.assertIn(self.R1.name, barcode) def test_copy(self): - self.R2.deepCopy(self.R1, image=True, bom=True) + self.R2.deep_copy(self.R1, image=True, bom=True) def test_match_names(self): diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 2e75f22aac..7eb7ff3c65 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -360,7 +360,7 @@ class MakePartVariant(AjaxCreateView): parameters_copy = str2bool(request.POST.get('parameters_copy', False)) # Copy relevent information from the template part - part.deepCopy(part_template, bom=bom_copy, parameters=parameters_copy) + part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy) return self.renderJsonResponse(request, form, data, context=context) @@ -473,7 +473,7 @@ class PartDuplicate(AjaxCreateView): original = self.get_part_to_copy() if original: - part.deepCopy(original, bom=bom_copy, parameters=parameters_copy) + part.deep_copy(original, bom=bom_copy, parameters=parameters_copy) try: data['url'] = part.get_absolute_url() diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index e0eb0ec83e..b18daacd5e 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -175,7 +175,7 @@ class StockItem(MPTTModel): if add_note: # This StockItem is being saved for the first time self.addTransactionNote( - 'Created stock item', + _('Created stock item'), user, notes="Created new stock item for part '{p}'".format(p=str(self.part)), system=True @@ -199,13 +199,7 @@ class StockItem(MPTTModel): """ super(StockItem, self).validate_unique(exclude) - - # If the part is trackable, either serial number or batch number must be set - if self.part.trackable: - if not self.serial and not self.batch: - msg = _('Serial or batch number must be specified for trackable stock') - raise ValidationError(msg) - + # If the serial number is set, make sure it is not a duplicate if self.serial is not None: # Query to look for duplicate serial numbers diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js index 64703a5097..c55429faba 100644 --- a/InvenTree/templates/js/bom.js +++ b/InvenTree/templates/js/bom.js @@ -152,17 +152,7 @@ function loadBomTable(table, options) { var sub_part = row.sub_part_detail; - if (sub_part.trackable) { - html += makeIconBadge('fa-directions', '{% trans "Trackable part" %}'); - } - - if (sub_part.virtual) { - html += makeIconBadge('fa-ghost', '{% trans "Virtual part" %}'); - } - - if (sub_part.is_template) { - html += makeIconBadge('fa-clone', '{% trans "Template part" %}'); - } + html += makePartIcons(row.sub_part_detail); // Display an extra icon if this part is an assembly if (sub_part.assembly) { @@ -171,10 +161,6 @@ function loadBomTable(table, options) { html += renderLink(text, `/part/${row.sub_part}/bom/`); } - if (!sub_part.active) { - html += `{% trans "Inactive" %}`; - } - return html; } } diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js index 36f21e2245..d6f3681036 100644 --- a/InvenTree/templates/js/build.js +++ b/InvenTree/templates/js/build.js @@ -36,11 +36,8 @@ function makeBuildOutputActionButtons(output, buildInfo) { /* Generate action buttons for a build output. */ - var outputId = 'untracked'; - - if (output) { - outputId = output.pk; - } + var buildId = buildInfo.pk; + var outputId = output.pk; var panel = `#allocation-panel-${outputId}`; @@ -54,23 +51,19 @@ function makeBuildOutputActionButtons(output, buildInfo) { var html = `
`; // Add a button to "auto allocate" against the build - if (!output) { - html += makeIconButton( - 'fa-magic icon-blue', 'button-output-auto', outputId, - '{% trans "Auto-allocate stock items to this output" %}', - ); - } + html += makeIconButton( + 'fa-magic icon-blue', 'button-output-auto', outputId, + '{% trans "Auto-allocate stock items to this output" %}', + ); - if (output) { - // Add a button to "complete" the particular build output - html += makeIconButton( - 'fa-check icon-green', 'button-output-complete', outputId, - '{% trans "Complete build output" %}', - { - disabled: true - } - ); - } + // Add a button to "complete" the particular build output + html += makeIconButton( + 'fa-check icon-green', 'button-output-complete', outputId, + '{% trans "Complete build output" %}', + { + //disabled: true + } + ); // Add a button to "cancel" the particular build output (unallocate) html += makeIconButton( @@ -78,18 +71,14 @@ function makeBuildOutputActionButtons(output, buildInfo) { '{% trans "Unallocate stock from build output" %}', ); - if (output) { - // Add a button to "delete" the particular build output - html += makeIconButton( - 'fa-trash-alt icon-red', 'button-output-delete', outputId, - '{% trans "Delete build output" %}', - ); - - // Add a button to "destroy" the particular build output (mark as damaged, scrap) - // TODO - - } + // Add a button to "delete" the particular build output + html += makeIconButton( + 'fa-trash-alt icon-red', 'button-output-delete', outputId, + '{% trans "Delete build output" %}', + ); + // Add a button to "destroy" the particular build output (mark as damaged, scrap) + // TODO html += '
'; @@ -103,13 +92,21 @@ function makeBuildOutputActionButtons(output, buildInfo) { data: { output: outputId, }, - reload: true, + success: reloadTable, } ); }); $(panel).find(`#button-output-complete-${outputId}`).click(function() { - // TODO + launchModalForm( + `/build/${buildId}/complete/`, + { + success: reloadTable, + data: { + output: outputId, + } + } + ); }); $(panel).find(`#button-output-unallocate-${outputId}`).click(function() { @@ -155,18 +152,12 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var outputId = null; - if (output) { - outputId = output.pk; - } + outputId = output.pk; var table = options.table; if (options.table == null) { - if (outputId != null) { - table = `#allocation-table-${outputId}`; - } else { - table = `#allocation-table-untracked`; - } + table = `#allocation-table-${outputId}`; } function reloadTable() { @@ -177,14 +168,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { function requiredQuantity(row) { // Return the requied quantity for a given row - if (output) { - // Tracked stock allocated against a particular BuildOutput - return row.quantity * output.quantity; - } else { - // Untrack stock allocated against the build - return row.quantity * (buildInfo.quantity - buildInfo.completed); - } - + return row.quantity * output.quantity; } function sumAllocations(row) { @@ -300,7 +284,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { queryParams: { part: partId, sub_part_detail: true, - sub_part_trackable: outputId != null, }, formatNoMatches: function() { return '{% trans "No BOM items found" %}'; @@ -357,14 +340,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var allocatedQuantity = sumAllocations(tableRow); // Is this line item fully allocated? - if (output) { - if (allocatedQuantity >= (tableRow.quantity * output.quantity)) { - allocatedLines += 1; - } - } else { - if (allocatedQuantity >= (tableRow.quantity * (buildInfo.quantity - buildInfo.completed))) { - allocatedLines += 1; - } + if (allocatedQuantity >= (tableRow.quantity * output.quantity)) { + allocatedLines += 1; } // Push the updated row back into the main table @@ -506,6 +483,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var html = imageHoverIcon(thumb) + renderLink(name, url); + html += makePartIcons(row.sub_part_detail); + return html; } }, @@ -547,13 +526,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var qA = rowA.quantity; var qB = rowB.quantity; - if (output) { - qA *= output.quantity; - qB *= output.quantity; - } else { - qA *= buildInfo.quantity; - qB *= buildInfo.quantity; - } + qA *= output.quantity; + qB *= output.quantity; // Handle the case where both numerators are zero if ((aA == 0) && (aB == 0)) { diff --git a/InvenTree/templates/js/part.js b/InvenTree/templates/js/part.js index f95f353582..8c00625dfc 100644 --- a/InvenTree/templates/js/part.js +++ b/InvenTree/templates/js/part.js @@ -61,6 +61,45 @@ function toggleStar(options) { } +function makePartIcons(part, options={}) { + /* Render a set of icons for the given part. + */ + + var html = ''; + + if (part.trackable) { + html += makeIconBadge('fa-directions', '{% trans "Trackable part" %}'); + } + + if (part.virtual) { + html += makeIconBadge('fa-ghost', '{% trans "Virtual part" %}'); + } + + if (part.is_template) { + html += makeIconBadge('fa-clone', '{% trans "Template part" %}'); + } + + if (part.assembly) { + html += makeIconBadge('fa-tools', '{% trans "Assembled part" %}'); + } + + if (part.starred) { + html += makeIconBadge('fa-star', '{% trans "Starred part" %}'); + } + + if (part.salable) { + html += makeIconBadge('fa-dollar-sign', title='{% trans "Salable part" %}'); + } + + if (!part.active) { + html += `{% trans "Inactive" %}`; + } + + return html; + +} + + function loadPartVariantTable(table, partId, options={}) { /* Load part variant table */ @@ -340,40 +379,8 @@ function loadPartTable(table, url, options={}) { var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/'); - if (row.trackable) { - display += makeIconBadge('fa-directions', '{% trans "Trackable part" %}'); - } + display += makePartIcons(row); - if (row.virtual) { - display += makeIconBadge('fa-ghost', '{% trans "Virtual part" %}'); - } - - - if (row.is_template) { - display += makeIconBadge('fa-clone', '{% trans "Template part" %}'); - } - - if (row.assembly) { - display += makeIconBadge('fa-tools', '{% trans "Assembled part" %}'); - } - - if (row.starred) { - display += makeIconBadge('fa-star', '{% trans "Starred part" %}'); - } - - if (row.salable) { - display += makeIconBadge('fa-dollar-sign', title='{% trans "Salable part" %}'); - } - - /* - if (row.component) { - display = display + ``; - } - */ - - if (!row.active) { - display += `{% trans "Inactive" %}`; - } return display; } }); diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 78323b039e..9339cdeda7 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -908,7 +908,7 @@ function loadInstalledInTable(table, options) { url: "{% url 'api-bom-list' %}", queryParams: { part: options.part, - trackable: true, + sub_part_trackable: true, sub_part_detail: true, }, showColumns: false,
{% trans "Enough Parts?" %} - {% if build.can_build %} - {% trans "Yes" %} - {% else %} - {% trans "No" %} - {% endif %} -