mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge branch 'inventree:master' into plugin-2037
This commit is contained in:
		| @@ -933,7 +933,8 @@ input[type="submit"] { | |||||||
|  |  | ||||||
| .panel-inventree { | .panel-inventree { | ||||||
|     padding: 10px; |     padding: 10px; | ||||||
|     box-shadow: 1px 1px #DDD; |     box-shadow: 2px 2px #DDD; | ||||||
|  |     border-color: #ccc; | ||||||
| } | } | ||||||
|  |  | ||||||
| .panel-hidden { | .panel-hidden { | ||||||
| @@ -1074,6 +1075,14 @@ input[type='number']{ | |||||||
|     margin-top: 0.5rem; |     margin-top: 0.5rem; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .product-card { | ||||||
|  |     width: 20%; | ||||||
|  |     padding: 5px; | ||||||
|  |     min-height: 25px; | ||||||
|  | } | ||||||
|  |  | ||||||
| .product-card-panel{ | .product-card-panel{ | ||||||
|     height: 100%; |     height: 100%; | ||||||
|  |     border: 1px solid #ccc; | ||||||
|  |     box-shadow: 2px 2px #DDD; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,12 +0,0 @@ | |||||||
| var msDelay = 0; |  | ||||||
|  |  | ||||||
| var delay = (function(){ |  | ||||||
|     return function(callback, ms){ |  | ||||||
|         clearTimeout(msDelay); |  | ||||||
|         msDelay = setTimeout(callback, ms); |  | ||||||
|     }; |  | ||||||
| })(); |  | ||||||
|  |  | ||||||
| function cancelTimer(){ |  | ||||||
|     clearTimeout(msDelay); |  | ||||||
| } |  | ||||||
| @@ -1,249 +0,0 @@ | |||||||
| function loadTree(url, tree, options={}) { |  | ||||||
|     /* Load the side-nav tree view |  | ||||||
|  |  | ||||||
|     Args: |  | ||||||
|         url: URL to request tree data |  | ||||||
|         tree: html ref to treeview |  | ||||||
|         options: |  | ||||||
|             data: data object to pass to the AJAX request |  | ||||||
|             selected: ID of currently selected item |  | ||||||
|             name: name of the tree |  | ||||||
|     */ |  | ||||||
|  |  | ||||||
|     var data = {}; |  | ||||||
|  |  | ||||||
|     if (options.data) { |  | ||||||
|         data = options.data; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     var key = "inventree-sidenav-items-"; |  | ||||||
|  |  | ||||||
|     if (options.name) { |  | ||||||
|         key += options.name; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     $.ajax({ |  | ||||||
|         url: url, |  | ||||||
|         type: 'get', |  | ||||||
|         dataType: 'json', |  | ||||||
|         data: data, |  | ||||||
|         success: function (response) { |  | ||||||
|             if (response.tree) { |  | ||||||
|                 $(tree).treeview({ |  | ||||||
|                     data: response.tree, |  | ||||||
|                     enableLinks: true, |  | ||||||
|                     showTags: true, |  | ||||||
|                 }); |  | ||||||
|  |  | ||||||
|                 if (localStorage.getItem(key)) { |  | ||||||
|                     var saved_exp = localStorage.getItem(key).split(","); |  | ||||||
|  |  | ||||||
|                     // Automatically expand the desired notes |  | ||||||
|                     for (var q = 0; q < saved_exp.length; q++) { |  | ||||||
|                         $(tree).treeview('expandNode', parseInt(saved_exp[q])); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 // Setup a callback whenever a node is toggled |  | ||||||
|                 $(tree).on('nodeExpanded nodeCollapsed', function(event, data) { |  | ||||||
|                      |  | ||||||
|                     // Record the entire list of expanded items |  | ||||||
|                     var expanded = $(tree).treeview('getExpanded'); |  | ||||||
|  |  | ||||||
|                     var exp = []; |  | ||||||
|  |  | ||||||
|                     for (var i = 0; i < expanded.length; i++) { |  | ||||||
|                         exp.push(expanded[i].nodeId); |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     // Save the expanded nodes |  | ||||||
|                     localStorage.setItem(key, exp); |  | ||||||
|                 }); |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         error: function (xhr, ajaxOptions, thrownError) { |  | ||||||
|             //TODO |  | ||||||
|         } |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initialize navigation tree display |  | ||||||
|  */ |  | ||||||
| function initNavTree(options) { |  | ||||||
|  |  | ||||||
|     var resize = true; |  | ||||||
|  |  | ||||||
|     if ('resize' in options) { |  | ||||||
|         resize = options.resize; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     var label = options.label || 'nav'; |  | ||||||
|  |  | ||||||
|     var stateLabel = `${label}-tree-state`; |  | ||||||
|     var widthLabel = `${label}-tree-width`; |  | ||||||
|  |  | ||||||
|     var treeId = options.treeId || '#sidenav-left'; |  | ||||||
|     var toggleId = options.toggleId; |  | ||||||
|  |  | ||||||
|     // Initially hide the tree |  | ||||||
|     $(treeId).animate({ |  | ||||||
|         width: '0px', |  | ||||||
|     }, 0, function() { |  | ||||||
|  |  | ||||||
|         if (resize) { |  | ||||||
|             $(treeId).resizable({ |  | ||||||
|                 minWidth: '0px', |  | ||||||
|                 maxWidth: '500px', |  | ||||||
|                 handles: 'e, se', |  | ||||||
|                 grid: [5, 5], |  | ||||||
|                 stop: function(event, ui) { |  | ||||||
|                     var width = Math.round(ui.element.width()); |  | ||||||
|  |  | ||||||
|                     if (width < 75) { |  | ||||||
|                         $(treeId).animate({ |  | ||||||
|                             width: '0px' |  | ||||||
|                         }, 50); |  | ||||||
|  |  | ||||||
|                         localStorage.setItem(stateLabel, 'closed'); |  | ||||||
|                     } else { |  | ||||||
|                         localStorage.setItem(stateLabel, 'open'); |  | ||||||
|                         localStorage.setItem(widthLabel, `${width}px`); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var state = localStorage.getItem(stateLabel); |  | ||||||
|         var width = localStorage.getItem(widthLabel) || '300px'; |  | ||||||
|  |  | ||||||
|         if (state && state == 'open') { |  | ||||||
|  |  | ||||||
|             $(treeId).animate({ |  | ||||||
|                 width: width, |  | ||||||
|             }, 50); |  | ||||||
|         } |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     // Register callback for 'toggle' button |  | ||||||
|     if (toggleId) { |  | ||||||
|          |  | ||||||
|         $(toggleId).click(function() { |  | ||||||
|  |  | ||||||
|             var state = localStorage.getItem(stateLabel) || 'closed'; |  | ||||||
|             var width = localStorage.getItem(widthLabel) || '300px'; |  | ||||||
|  |  | ||||||
|             if (state == 'open') { |  | ||||||
|                 $(treeId).animate({ |  | ||||||
|                     width: '0px' |  | ||||||
|                 }, 50); |  | ||||||
|  |  | ||||||
|                 localStorage.setItem(stateLabel, 'closed'); |  | ||||||
|             } else { |  | ||||||
|                 $(treeId).animate({ |  | ||||||
|                     width: width, |  | ||||||
|                 }, 50); |  | ||||||
|  |  | ||||||
|                 localStorage.setItem(stateLabel, 'open'); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Handle left-hand icon menubar display |  | ||||||
|  */ |  | ||||||
| function enableNavbar(options) { |  | ||||||
|  |  | ||||||
|     var resize = true; |  | ||||||
|  |  | ||||||
|     if ('resize' in options) { |  | ||||||
|         resize = options.resize; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     var label = options.label || 'nav'; |  | ||||||
|  |  | ||||||
|     label = `navbar-${label}`; |  | ||||||
|  |  | ||||||
|     var stateLabel = `${label}-state`; |  | ||||||
|     var widthLabel = `${label}-width`; |  | ||||||
|  |  | ||||||
|     var navId = options.navId || '#sidenav-right'; |  | ||||||
|  |  | ||||||
|     var toggleId = options.toggleId; |  | ||||||
|  |  | ||||||
|     // Extract the saved width for this element |  | ||||||
|     $(navId).animate({ |  | ||||||
|         width: '45px', |  | ||||||
|         'min-width': '45px', |  | ||||||
|         display: 'block', |  | ||||||
|     }, 50, function() { |  | ||||||
|  |  | ||||||
|         // Make the navbar resizable |  | ||||||
|         if (resize) { |  | ||||||
|             $(navId).resizable({ |  | ||||||
|                 minWidth: options.minWidth || '100px', |  | ||||||
|                 maxWidth: options.maxWidth || '500px', |  | ||||||
|                 handles: 'e, se', |  | ||||||
|                 grid: [5, 5], |  | ||||||
|                 stop: function(event, ui) { |  | ||||||
|                     // Record the new width |  | ||||||
|                     var width = Math.round(ui.element.width()); |  | ||||||
|  |  | ||||||
|                     // Reasonably narrow? Just close it! |  | ||||||
|                     if (width <= 75) { |  | ||||||
|                         $(navId).animate({ |  | ||||||
|                             width: '45px' |  | ||||||
|                         }, 50); |  | ||||||
|  |  | ||||||
|                         localStorage.setItem(stateLabel, 'closed'); |  | ||||||
|                     } else { |  | ||||||
|                         localStorage.setItem(widthLabel, `${width}px`); |  | ||||||
|                         localStorage.setItem(stateLabel, 'open'); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var state = localStorage.getItem(stateLabel); |  | ||||||
|  |  | ||||||
|         var width = localStorage.getItem(widthLabel) || '250px'; |  | ||||||
|          |  | ||||||
|         if (state && state == 'open') { |  | ||||||
|  |  | ||||||
|             $(navId).animate({ |  | ||||||
|                 width: width |  | ||||||
|             }, 100); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     // Register callback for 'toggle' button |  | ||||||
|     if (toggleId) { |  | ||||||
|  |  | ||||||
|         $(toggleId).click(function() { |  | ||||||
|  |  | ||||||
|             var state = localStorage.getItem(stateLabel) || 'closed'; |  | ||||||
|             var width = localStorage.getItem(widthLabel) || '250px'; |  | ||||||
|  |  | ||||||
|             if (state == 'open') { |  | ||||||
|                 $(navId).animate({ |  | ||||||
|                     width: '45px', |  | ||||||
|                     minWidth: '45px', |  | ||||||
|                 }, 50); |  | ||||||
|  |  | ||||||
|                 localStorage.setItem(stateLabel, 'closed'); |  | ||||||
|  |  | ||||||
|             } else { |  | ||||||
|  |  | ||||||
|                 $(navId).animate({ |  | ||||||
|                     'width': width |  | ||||||
|                 }, 50); |  | ||||||
|  |  | ||||||
|                 localStorage.setItem(stateLabel, 'open'); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -12,11 +12,14 @@ import common.models | |||||||
| INVENTREE_SW_VERSION = "0.6.0 dev" | INVENTREE_SW_VERSION = "0.6.0 dev" | ||||||
|  |  | ||||||
| # InvenTree API version | # InvenTree API version | ||||||
| INVENTREE_API_VERSION = 15 | INVENTREE_API_VERSION = 16 | ||||||
|  |  | ||||||
| """ | """ | ||||||
| Increment this API version number whenever there is a significant change to the API that any clients need to know about | Increment this API version number whenever there is a significant change to the API that any clients need to know about | ||||||
|  |  | ||||||
|  | v16 -> 2021-10-17 | ||||||
|  |     - Adds API endpoint for completing build order outputs | ||||||
|  |  | ||||||
| v15 -> 2021-10-06 | v15 -> 2021-10-06 | ||||||
|     - Adds detail endpoint for SalesOrderAllocation model |     - Adds detail endpoint for SalesOrderAllocation model | ||||||
|     - Allows use of the API forms interface for adjusting SalesOrderAllocation objects |     - Allows use of the API forms interface for adjusting SalesOrderAllocation objects | ||||||
|   | |||||||
| @@ -5,12 +5,9 @@ JSON API for the Build app | |||||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||||
| from __future__ import unicode_literals | from __future__ import unicode_literals | ||||||
|  |  | ||||||
| from django.utils.translation import ugettext_lazy as _ |  | ||||||
|  |  | ||||||
| from django.conf.urls import url, include | from django.conf.urls import url, include | ||||||
|  |  | ||||||
| from rest_framework import filters, generics | from rest_framework import filters, generics | ||||||
| from rest_framework.serializers import ValidationError |  | ||||||
|  |  | ||||||
| from django_filters.rest_framework import DjangoFilterBackend | from django_filters.rest_framework import DjangoFilterBackend | ||||||
| from django_filters import rest_framework as rest_filters | from django_filters import rest_framework as rest_filters | ||||||
| @@ -21,7 +18,7 @@ from InvenTree.filters import InvenTreeOrderingFilter | |||||||
| from InvenTree.status_codes import BuildStatus | from InvenTree.status_codes import BuildStatus | ||||||
|  |  | ||||||
| from .models import Build, BuildItem, BuildOrderAttachment | from .models import Build, BuildItem, BuildOrderAttachment | ||||||
| from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer | from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer | ||||||
| from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer | from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -201,30 +198,43 @@ class BuildUnallocate(generics.CreateAPIView): | |||||||
|     queryset = Build.objects.none() |     queryset = Build.objects.none() | ||||||
|  |  | ||||||
|     serializer_class = BuildUnallocationSerializer |     serializer_class = BuildUnallocationSerializer | ||||||
|  |      | ||||||
|     def get_build(self): |  | ||||||
|         """ |  | ||||||
|         Returns the BuildOrder associated with this API endpoint |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         pk = self.kwargs.get('pk', None) |  | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             build = Build.objects.get(pk=pk) |  | ||||||
|         except (ValueError, Build.DoesNotExist): |  | ||||||
|             raise ValidationError(_("Matching build order does not exist")) |  | ||||||
|  |  | ||||||
|         return build |  | ||||||
|  |  | ||||||
|     def get_serializer_context(self): |     def get_serializer_context(self): | ||||||
|  |  | ||||||
|         ctx = super().get_serializer_context() |         ctx = super().get_serializer_context() | ||||||
|         ctx['build'] = self.get_build() |  | ||||||
|  |         try: | ||||||
|  |             ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) | ||||||
|  |         except: | ||||||
|  |             pass | ||||||
|  |  | ||||||
|         ctx['request'] = self.request |         ctx['request'] = self.request | ||||||
|  |  | ||||||
|         return ctx |         return ctx | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BuildComplete(generics.CreateAPIView): | ||||||
|  |     """ | ||||||
|  |     API endpoint for completing build outputs | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     queryset = Build.objects.none() | ||||||
|  |  | ||||||
|  |     serializer_class = BuildCompleteSerializer | ||||||
|  |  | ||||||
|  |     def get_serializer_context(self): | ||||||
|  |         ctx = super().get_serializer_context() | ||||||
|  |  | ||||||
|  |         ctx['request'] = self.request | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) | ||||||
|  |         except: | ||||||
|  |             pass | ||||||
|  |          | ||||||
|  |         return ctx | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildAllocate(generics.CreateAPIView): | class BuildAllocate(generics.CreateAPIView): | ||||||
|     """ |     """ | ||||||
|     API endpoint to allocate stock items to a build order |     API endpoint to allocate stock items to a build order | ||||||
| @@ -241,20 +251,6 @@ class BuildAllocate(generics.CreateAPIView): | |||||||
|  |  | ||||||
|     serializer_class = BuildAllocationSerializer |     serializer_class = BuildAllocationSerializer | ||||||
|  |  | ||||||
|     def get_build(self): |  | ||||||
|         """ |  | ||||||
|         Returns the BuildOrder associated with this API endpoint |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         pk = self.kwargs.get('pk', None) |  | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             build = Build.objects.get(pk=pk) |  | ||||||
|         except (Build.DoesNotExist, ValueError): |  | ||||||
|             raise ValidationError(_("Matching build order does not exist")) |  | ||||||
|  |  | ||||||
|         return build |  | ||||||
|  |  | ||||||
|     def get_serializer_context(self): |     def get_serializer_context(self): | ||||||
|         """ |         """ | ||||||
|         Provide the Build object to the serializer context |         Provide the Build object to the serializer context | ||||||
| @@ -262,7 +258,11 @@ class BuildAllocate(generics.CreateAPIView): | |||||||
|  |  | ||||||
|         context = super().get_serializer_context() |         context = super().get_serializer_context() | ||||||
|  |  | ||||||
|         context['build'] = self.get_build() |         try: | ||||||
|  |             context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) | ||||||
|  |         except: | ||||||
|  |             pass | ||||||
|  |  | ||||||
|         context['request'] = self.request |         context['request'] = self.request | ||||||
|  |  | ||||||
|         return context |         return context | ||||||
| @@ -390,6 +390,7 @@ build_api_urls = [ | |||||||
|     # Build Detail |     # Build Detail | ||||||
|     url(r'^(?P<pk>\d+)/', include([ |     url(r'^(?P<pk>\d+)/', include([ | ||||||
|         url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), |         url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), | ||||||
|  |         url(r'^complete/', BuildComplete.as_view(), name='api-build-complete'), | ||||||
|         url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), |         url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), | ||||||
|         url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), |         url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), | ||||||
|     ])), |     ])), | ||||||
|   | |||||||
| @@ -10,63 +10,9 @@ from django.utils.translation import ugettext_lazy as _ | |||||||
| from django import forms | from django import forms | ||||||
|  |  | ||||||
| from InvenTree.forms import HelperForm | from InvenTree.forms import HelperForm | ||||||
| from InvenTree.fields import RoundingDecimalFormField |  | ||||||
| from InvenTree.fields import DatePickerFormField |  | ||||||
|  |  | ||||||
| from InvenTree.status_codes import StockStatus |  | ||||||
|  |  | ||||||
| from .models import Build | from .models import Build | ||||||
|  |  | ||||||
| from stock.models import StockLocation, StockItem |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class EditBuildForm(HelperForm): |  | ||||||
|     """ Form for editing a Build object. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     field_prefix = { |  | ||||||
|         'reference': 'BO', |  | ||||||
|         'link': 'fa-link', |  | ||||||
|         'batch': 'fa-layer-group', |  | ||||||
|         'serial-numbers': 'fa-hashtag', |  | ||||||
|         'location': 'fa-map-marker-alt', |  | ||||||
|         'target_date': 'fa-calendar-alt', |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     field_placeholder = { |  | ||||||
|         'reference': _('Build Order reference'), |  | ||||||
|         'target_date': _('Order target date'), |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     target_date = DatePickerFormField( |  | ||||||
|         label=_('Target Date'), |  | ||||||
|         help_text=_('Target date for build completion. Build will be overdue after this date.') |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     quantity = RoundingDecimalFormField( |  | ||||||
|         max_digits=10, decimal_places=5, |  | ||||||
|         label=_('Quantity'), |  | ||||||
|         help_text=_('Number of items to build') |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = Build |  | ||||||
|         fields = [ |  | ||||||
|             'reference', |  | ||||||
|             'title', |  | ||||||
|             'part', |  | ||||||
|             'quantity', |  | ||||||
|             'batch', |  | ||||||
|             'target_date', |  | ||||||
|             'take_from', |  | ||||||
|             'destination', |  | ||||||
|             'parent', |  | ||||||
|             'sales_order', |  | ||||||
|             'link', |  | ||||||
|             'issued_by', |  | ||||||
|             'responsible', |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildOutputCreateForm(HelperForm): | class BuildOutputCreateForm(HelperForm): | ||||||
|     """ |     """ | ||||||
| @@ -155,59 +101,6 @@ class CompleteBuildForm(HelperForm): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class CompleteBuildOutputForm(HelperForm): |  | ||||||
|     """ |  | ||||||
|     Form for completing a single build output |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     field_prefix = { |  | ||||||
|         'serial_numbers': 'fa-hashtag', |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     field_placeholder = { |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     location = forms.ModelChoiceField( |  | ||||||
|         queryset=StockLocation.objects.all(), |  | ||||||
|         label=_('Location'), |  | ||||||
|         help_text=_('Location of completed parts'), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     stock_status = forms.ChoiceField( |  | ||||||
|         label=_('Status'), |  | ||||||
|         help_text=_('Build output stock status'), |  | ||||||
|         initial=StockStatus.OK, |  | ||||||
|         choices=StockStatus.items(), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     confirm_incomplete = forms.BooleanField( |  | ||||||
|         required=False, |  | ||||||
|         label=_('Confirm incomplete'), |  | ||||||
|         help_text=_("Confirm completion with incomplete stock allocation") |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     confirm = forms.BooleanField(required=True, label=_('Confirm'), 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 = [ |  | ||||||
|             'location', |  | ||||||
|             'output', |  | ||||||
|             'stock_status', |  | ||||||
|             'confirm', |  | ||||||
|             'confirm_incomplete', |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|     def __init__(self, *args, **kwargs): |  | ||||||
|  |  | ||||||
|         super().__init__(*args, **kwargs) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class CancelBuildForm(HelperForm): | class CancelBuildForm(HelperForm): | ||||||
|     """ Form for cancelling a build """ |     """ Form for cancelling a build """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -724,7 +724,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): | |||||||
|         items.all().delete() |         items.all().delete() | ||||||
|  |  | ||||||
|     @transaction.atomic |     @transaction.atomic | ||||||
|     def completeBuildOutput(self, output, user, **kwargs): |     def complete_build_output(self, output, user, **kwargs): | ||||||
|         """ |         """ | ||||||
|         Complete a particular build output |         Complete a particular build output | ||||||
|  |  | ||||||
| @@ -741,10 +741,6 @@ class Build(MPTTModel, ReferenceIndexingMixin): | |||||||
|         allocated_items = output.items_to_install.all() |         allocated_items = output.items_to_install.all() | ||||||
|  |  | ||||||
|         for build_item in allocated_items: |         for build_item in allocated_items: | ||||||
|  |  | ||||||
|             # TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete |  | ||||||
|             # TODO: Use the background worker process to handle this task! |  | ||||||
|  |  | ||||||
|             # Complete the allocation of stock for that item |             # Complete the allocation of stock for that item | ||||||
|             build_item.complete_allocation(user) |             build_item.complete_allocation(user) | ||||||
|  |  | ||||||
| @@ -770,6 +766,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): | |||||||
|  |  | ||||||
|         # Increase the completed quantity for this build |         # Increase the completed quantity for this build | ||||||
|         self.completed += output.quantity |         self.completed += output.quantity | ||||||
|  |  | ||||||
|         self.save() |         self.save() | ||||||
|  |  | ||||||
|     def requiredQuantity(self, part, output): |     def requiredQuantity(self, part, output): | ||||||
|   | |||||||
| @@ -18,9 +18,10 @@ from rest_framework.serializers import ValidationError | |||||||
| from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer | from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer | ||||||
| from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief | from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief | ||||||
|  |  | ||||||
|  | from InvenTree.status_codes import StockStatus | ||||||
| import InvenTree.helpers | import InvenTree.helpers | ||||||
|  |  | ||||||
| from stock.models import StockItem | from stock.models import StockItem, StockLocation | ||||||
| from stock.serializers import StockItemSerializerBrief, LocationSerializer | from stock.serializers import StockItemSerializerBrief, LocationSerializer | ||||||
|  |  | ||||||
| from part.models import BomItem | from part.models import BomItem | ||||||
| @@ -120,6 +121,124 @@ class BuildSerializer(InvenTreeModelSerializer): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BuildOutputSerializer(serializers.Serializer): | ||||||
|  |     """ | ||||||
|  |     Serializer for a "BuildOutput" | ||||||
|  |  | ||||||
|  |     Note that a "BuildOutput" is really just a StockItem which is "in production"! | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     output = serializers.PrimaryKeyRelatedField( | ||||||
|  |         queryset=StockItem.objects.all(), | ||||||
|  |         many=False, | ||||||
|  |         allow_null=False, | ||||||
|  |         required=True, | ||||||
|  |         label=_('Build Output'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     def validate_output(self, output): | ||||||
|  |  | ||||||
|  |         build = self.context['build'] | ||||||
|  |  | ||||||
|  |         # The stock item must point to the build | ||||||
|  |         if output.build != build: | ||||||
|  |             raise ValidationError(_("Build output does not match the parent build")) | ||||||
|  |  | ||||||
|  |         # The part must match! | ||||||
|  |         if output.part != build.part: | ||||||
|  |             raise ValidationError(_("Output part does not match BuildOrder part")) | ||||||
|  |  | ||||||
|  |         # The build output must be "in production" | ||||||
|  |         if not output.is_building: | ||||||
|  |             raise ValidationError(_("This build output has already been completed")) | ||||||
|  |  | ||||||
|  |         # The build output must have all tracked parts allocated | ||||||
|  |         if not build.isFullyAllocated(output): | ||||||
|  |             raise ValidationError(_("This build output is not fully allocated")) | ||||||
|  |  | ||||||
|  |         return output | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         fields = [ | ||||||
|  |             'output', | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BuildCompleteSerializer(serializers.Serializer): | ||||||
|  |     """ | ||||||
|  |     DRF serializer for completing one or more build outputs | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         fields = [ | ||||||
|  |             'outputs', | ||||||
|  |             'location', | ||||||
|  |             'status', | ||||||
|  |             'notes', | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |     outputs = BuildOutputSerializer( | ||||||
|  |         many=True, | ||||||
|  |         required=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     location = serializers.PrimaryKeyRelatedField( | ||||||
|  |         queryset=StockLocation.objects.all(), | ||||||
|  |         required=True, | ||||||
|  |         many=False, | ||||||
|  |         label=_("Location"), | ||||||
|  |         help_text=_("Location for completed build outputs"), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     status = serializers.ChoiceField( | ||||||
|  |         choices=list(StockStatus.items()), | ||||||
|  |         default=StockStatus.OK, | ||||||
|  |         label=_("Status"), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     notes = serializers.CharField( | ||||||
|  |         label=_("Notes"), | ||||||
|  |         required=False, | ||||||
|  |         allow_blank=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     def validate(self, data): | ||||||
|  |  | ||||||
|  |         super().validate(data) | ||||||
|  |  | ||||||
|  |         outputs = data.get('outputs', []) | ||||||
|  |  | ||||||
|  |         if len(outputs) == 0: | ||||||
|  |             raise ValidationError(_("A list of build outputs must be provided")) | ||||||
|  |  | ||||||
|  |         return data | ||||||
|  |  | ||||||
|  |     def save(self): | ||||||
|  |         """ | ||||||
|  |         "save" the serializer to complete the build outputs | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         build = self.context['build'] | ||||||
|  |         request = self.context['request'] | ||||||
|  |  | ||||||
|  |         data = self.validated_data | ||||||
|  |  | ||||||
|  |         outputs = data.get('outputs', []) | ||||||
|  |  | ||||||
|  |         # Mark the specified build outputs as "complete" | ||||||
|  |         with transaction.atomic(): | ||||||
|  |             for item in outputs: | ||||||
|  |  | ||||||
|  |                 output = item['output'] | ||||||
|  |  | ||||||
|  |                 build.complete_build_output( | ||||||
|  |                     output, | ||||||
|  |                     request.user, | ||||||
|  |                     status=data['status'], | ||||||
|  |                     notes=data.get('notes', '') | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildUnallocationSerializer(serializers.Serializer): | class BuildUnallocationSerializer(serializers.Serializer): | ||||||
|     """ |     """ | ||||||
|     DRF serializer for unallocating stock from a BuildOrder |     DRF serializer for unallocating stock from a BuildOrder | ||||||
| @@ -190,6 +309,8 @@ class BuildAllocationItemSerializer(serializers.Serializer): | |||||||
|  |  | ||||||
|     def validate_bom_item(self, bom_item): |     def validate_bom_item(self, bom_item): | ||||||
|          |          | ||||||
|  |         # TODO: Fix this validation - allow for variants and substitutes! | ||||||
|  |  | ||||||
|         build = self.context['build'] |         build = self.context['build'] | ||||||
|  |  | ||||||
|         # BomItem must point to the same 'part' as the parent build |         # BomItem must point to the same 'part' as the parent build | ||||||
|   | |||||||
| @@ -1,51 +0,0 @@ | |||||||
| {% load i18n %} |  | ||||||
| {% load inventree_extras %} |  | ||||||
|  |  | ||||||
| {% define item.pk as pk %} |  | ||||||
|  |  | ||||||
| <div class="panel panel-default" id='allocation-panel-{{ pk }}'> |  | ||||||
|     <div class="panel-heading" role="tab" id="heading-{{ pk }}"> |  | ||||||
|       <div class="panel-title"> |  | ||||||
|           <div class='row'> |  | ||||||
|               {% if tracked_items %} |  | ||||||
|               <a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}"> |  | ||||||
|               {% endif %} |  | ||||||
|                 <div class='col-sm-4'> |  | ||||||
|                     {% if tracked_items %} |  | ||||||
|                     <span class='fas fa-caret-right'></span> |  | ||||||
|                     {% endif %} |  | ||||||
|                     {{ item.part.full_name }} |  | ||||||
|                 </div> |  | ||||||
|                 <div class='col-sm-2'> |  | ||||||
|                     {% if item.serial %} |  | ||||||
|                     {% trans "Serial Number" %}: {{ item.serial }} |  | ||||||
|                     {% else %} |  | ||||||
|                     {% trans "Quantity" %}: {% decimal item.quantity %} |  | ||||||
|                     {% endif %} |  | ||||||
|                 </div> |  | ||||||
|             {% if tracked_items %} |  | ||||||
|             </a> |  | ||||||
|             {% endif %} |  | ||||||
|             <div class='col-sm-3'> |  | ||||||
|                 <div> |  | ||||||
|                     <div id='output-progress-{{ pk }}'> |  | ||||||
|                         {% if tracked_items %} |  | ||||||
|                         <span class='fas fa-spin fa-spinner'></span> |  | ||||||
|                         {% endif %} |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|             <div class='col-sm-3'> |  | ||||||
|                 <div class='btn-group float-right' id='output-actions-{{ pk }}'> |  | ||||||
|                     <span class='fas fa-spin fa-spinner'></span> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     </div> |  | ||||||
|     </div> |  | ||||||
|     <div id="collapse-{{ pk }}" class="panel-collapse collapse" role="tabpanel" aria-labelledby="heading-{{ pk }}"> |  | ||||||
|       <div class="panel-body"> |  | ||||||
|           <table class='table table-striped table-condensed' id='allocation-table-{{ pk }}'></table> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| @@ -91,16 +91,11 @@ src="{% static 'img/blank_image.png' %}" | |||||||
|             <span class='fas fa-print'></span> <span class='caret'></span> |             <span class='fas fa-print'></span> <span class='caret'></span> | ||||||
|         </button> |         </button> | ||||||
|         <ul class='dropdown-menu' role='menu'> |         <ul class='dropdown-menu' role='menu'> | ||||||
|             <li><a href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print Build Order" %}</a></li> |             <li><a href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print build order report" %}</a></li> | ||||||
|         </ul> |         </ul> | ||||||
|     </div> |     </div> | ||||||
|     <!-- Build actions --> |     <!-- Build actions --> | ||||||
|     {% if roles.build.change %} |     {% if roles.build.change %} | ||||||
|     {% if build.active %} |  | ||||||
|     <button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'> |  | ||||||
|         <span class='fas fa-paper-plane'></span> |  | ||||||
|     </button> |  | ||||||
|     {% endif %} |  | ||||||
|     <div class='btn-group'> |     <div class='btn-group'> | ||||||
|         <button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> |         <button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> | ||||||
|             <span class='fas fa-tools'></span> <span class='caret'></span> |             <span class='fas fa-tools'></span> <span class='caret'></span> | ||||||
| @@ -115,6 +110,11 @@ src="{% static 'img/blank_image.png' %}" | |||||||
|             {% endif %} |             {% endif %} | ||||||
|         </ul> |         </ul> | ||||||
|     </div> |     </div> | ||||||
|  |     {% if build.active %} | ||||||
|  |     <button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'> | ||||||
|  |         <span class='fas fa-check-circle'></span> | ||||||
|  |     </button> | ||||||
|  |     {% endif %} | ||||||
|     {% endif %} |     {% endif %} | ||||||
| </div> | </div> | ||||||
| {% endblock %} | {% endblock %} | ||||||
| @@ -153,8 +153,8 @@ src="{% static 'img/blank_image.png' %}" | |||||||
|     </tr> |     </tr> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|     <tr> |     <tr> | ||||||
|         <td><span class='fas fa-spinner'></span></td> |         <td><span class='fas fa-check-circle'></span></td> | ||||||
|         <td>{% trans "Progress" %}</td> |         <td>{% trans "Completed" %}</td> | ||||||
|         <td> {{ build.completed }} / {{ build.quantity }}</td> |         <td> {{ build.completed }} / {{ build.quantity }}</td> | ||||||
|     </tr> |     </tr> | ||||||
|     {% if build.parent %} |     {% if build.parent %} | ||||||
|   | |||||||
| @@ -1,53 +0,0 @@ | |||||||
| {% extends "modal_form.html" %} |  | ||||||
| {% load inventree_extras %} |  | ||||||
| {% load i18n %} |  | ||||||
|  |  | ||||||
| {% block pre_form_content %} |  | ||||||
|  |  | ||||||
| {% if not build.has_tracked_bom_items %} |  | ||||||
| {% elif fully_allocated %} |  | ||||||
| <div class='alert alert-block alert-success'> |  | ||||||
|     {% trans "Stock allocation is complete for this output" %} |  | ||||||
| </div> |  | ||||||
| {% else %} |  | ||||||
| <div class='alert alert-block alert-danger'> |  | ||||||
|     <h4>{% trans "Stock allocation is incomplete" %}</h4> |  | ||||||
|  |  | ||||||
|     <div class='panel-group'> |  | ||||||
|         <div class='panel panel-default'> |  | ||||||
|             <div class='panel panel-heading'> |  | ||||||
|                 <a data-toggle='collapse' href='#collapse-unallocated'> |  | ||||||
|                     {{ unallocated_parts|length }} {% trans "tracked parts have not been fully allocated" %} |  | ||||||
|                 </a> |  | ||||||
|             </div> |  | ||||||
|             <div class='panel-collapse collapse' id='collapse-unallocated'> |  | ||||||
|                 <div class='panel-body'> |  | ||||||
|                     <ul class='list-group'> |  | ||||||
|                         {% for part in unallocated_parts %} |  | ||||||
|                         <li class='list-group-item'> |  | ||||||
|                             {% include "hover_image.html" with image=part.image %} {{ part }} |  | ||||||
|                         </li> |  | ||||||
|                         {% endfor %} |  | ||||||
|                     </ul> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     </div> |  | ||||||
| </div> |  | ||||||
| {% endif %} |  | ||||||
|  |  | ||||||
| <div class='panel panel-info'> |  | ||||||
|     <div class='panel-heading'> |  | ||||||
|         {% trans "The following items will be created" %} |  | ||||||
|     </div> |  | ||||||
|     <div class='panel-content' style='padding-bottom:16px'> |  | ||||||
|         {% include "hover_image.html" with image=build.part.image %} |  | ||||||
|         {% if output.serialized %} |  | ||||||
|         {{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }} |  | ||||||
|         {% else %} |  | ||||||
|         {% decimal output.quantity %} x {{ output.part.full_name }} |  | ||||||
|         {% endif %} |  | ||||||
|     </div> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| {% endblock %} |  | ||||||
| @@ -63,10 +63,17 @@ | |||||||
|                     <td>{% build_status_label build.status %}</td> |                     <td>{% build_status_label build.status %}</td> | ||||||
|                 </tr> |                 </tr> | ||||||
|                 <tr> |                 <tr> | ||||||
|                     <td><span class='fas fa-spinner'></span></td> |                     <td><span class='fas fa-check-circle'></span></td> | ||||||
|                     <td>{% trans "Progress" %}</td> |                     <td>{% trans "Completed" %}</td> | ||||||
|                     <td>{{ build.completed }} / {{ build.quantity }}</td> |                     <td>{{ build.completed }} / {{ build.quantity }}</td> | ||||||
|                 </tr> |                 </tr> | ||||||
|  |                 {% if build.active and build.has_untracked_bom_items %} | ||||||
|  |                 <tr> | ||||||
|  |                     <td><span class='fas fa-list'></span></td> | ||||||
|  |                     <td>{% trans "Allocated Parts" %}</td> | ||||||
|  |                     <td id='output-progress-untracked'><span class='fas fa-spinner fa-spin'></span></td> | ||||||
|  |                 </tr> | ||||||
|  |                 {% endif %} | ||||||
|                 {% if build.batch %} |                 {% if build.batch %} | ||||||
|                 <tr> |                 <tr> | ||||||
|                     <td><span class='fas fa-layer-group'></span></td> |                     <td><span class='fas fa-layer-group'></span></td> | ||||||
| @@ -213,35 +220,35 @@ | |||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <div class='panel panel-default panel-inventree panel-hidden' id='panel-outputs'> | <div class='panel panel-default panel-inventree panel-hidden' id='panel-outputs'> | ||||||
|     {% if not build.is_complete %} |  | ||||||
|     <div class='panel-heading'> |     <div class='panel-heading'> | ||||||
|         <h4>{% trans "Incomplete Build Outputs" %}</h4> |         <h4>{% trans "Incomplete Build Outputs" %}</h4> | ||||||
|     </div> |     </div> | ||||||
|     <div class='panel-content'> |     <div class='panel-content'> | ||||||
|         <div class='btn-group' role='group'> |         <div id='build-output-toolbar'> | ||||||
|             {% if build.active %} |             <div class='button-toolbar container-fluid'> | ||||||
|             <button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'> |                 {% if build.active %} | ||||||
|                 <span class='fas fa-plus-circle'></span> {% trans "Create New Output" %} |                 <div class='btn-group'> | ||||||
|             </button> |                     <button class='btn btn-success' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'> | ||||||
|             {% endif %} |                         <span class='fas fa-plus-circle'></span> | ||||||
|  |                     </button> | ||||||
|  |                     <!-- Build output actions --> | ||||||
|  |                     <div class='btn-group'> | ||||||
|  |                         <button id='output-options' class='btn btn-primary dropdown-toiggle' type='button' data-toggle='dropdown' title='{% trans "Output Actions" %}'> | ||||||
|  |                             <span class='fas fa-tools'></span> <span class='caret'></span> | ||||||
|  |                         </button> | ||||||
|  |                         <ul class='dropdown-menu'> | ||||||
|  |                             <li><a href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}</a></li> | ||||||
|  |                         </ul> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |                 {% endif %} | ||||||
|  |             </div> | ||||||
|         </div> |         </div> | ||||||
|  |         <table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table> | ||||||
|         {% if build.incomplete_outputs %} |  | ||||||
|         <div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true"> |  | ||||||
|             {% for item in build.incomplete_outputs %} |  | ||||||
|             {% include "build/allocation_card.html" with item=item tracked_items=build.has_tracked_bom_items %} |  | ||||||
|             {% endfor %} |  | ||||||
|         </div> |  | ||||||
|         {% else %} |  | ||||||
|         <div class='alert alert-block alert-info'> |  | ||||||
|             <strong>{% trans "Create a new build output" %}</strong><br> |  | ||||||
|             {% trans "No incomplete build outputs remain." %}<br> |  | ||||||
|             {% trans "Create a new build output using the button above" %} |  | ||||||
|         </div> |  | ||||||
|         {% endif %} |  | ||||||
|     </div> |     </div> | ||||||
|     {% endif %} | </div> | ||||||
|  |  | ||||||
|  | <div class='panel panel-default panel-inventree panel-hidden' id='panel-completed'> | ||||||
|     <div class='panel-heading'> |     <div class='panel-heading'> | ||||||
|         <h4> |         <h4> | ||||||
|             {% trans "Completed Build Outputs" %} |             {% trans "Completed Build Outputs" %} | ||||||
| @@ -313,26 +320,75 @@ loadStockTable($("#build-stock-table"), { | |||||||
|     url: "{% url 'api-stock-list' %}",     |     url: "{% url 'api-stock-list' %}",     | ||||||
| }); | }); | ||||||
|  |  | ||||||
| var buildInfo = { |  | ||||||
|     pk: {{ build.pk }}, |  | ||||||
|     quantity: {{ build.quantity }}, |  | ||||||
|     completed: {{ build.completed }}, |  | ||||||
|     part: {{ build.part.pk }}, |  | ||||||
|     {% if build.take_from %} |  | ||||||
|     source_location: {{ build.take_from.pk }}, |  | ||||||
|     {% endif %} |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| {% for item in build.incomplete_outputs %} | // Get the list of BOM items required for this build | ||||||
| // Get the build output as a javascript object | inventreeGet( | ||||||
| inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, |     '{% url "api-bom-list" %}', | ||||||
|  |     { | ||||||
|  |         part: {{ build.part.pk }}, | ||||||
|  |         sub_part_detail: true, | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|         success: function(response) { |         success: function(response) { | ||||||
|             loadBuildOutputAllocationTable(buildInfo, response); |  | ||||||
|  |             var build_info = { | ||||||
|  |                 pk: {{ build.pk }}, | ||||||
|  |                 part: {{ build.part.pk }}, | ||||||
|  |                 quantity: {{ build.quantity }}, | ||||||
|  |                 bom_items: response, | ||||||
|  |                 {% if build.take_from %} | ||||||
|  |                 source_location: {{ build.take_from.pk }}, | ||||||
|  |                 {% endif %} | ||||||
|  |                 {% if build.has_tracked_bom_items %} | ||||||
|  |                 tracked_parts: true, | ||||||
|  |                 {% else %} | ||||||
|  |                 tracked_parts: false, | ||||||
|  |                 {% endif %} | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             {% if build.active %} | ||||||
|  |             loadBuildOutputTable(build_info); | ||||||
|  |             linkButtonsToSelection( | ||||||
|  |                 '#build-output-table', | ||||||
|  |                 [ | ||||||
|  |                     '#output-options', | ||||||
|  |                     '#multi-output-complete', | ||||||
|  |                 ] | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             $('#multi-output-complete').click(function() { | ||||||
|  |                 var outputs = $('#build-output-table').bootstrapTable('getSelections'); | ||||||
|  |  | ||||||
|  |                 completeBuildOutputs( | ||||||
|  |                     build_info.pk, | ||||||
|  |                     outputs, | ||||||
|  |                     { | ||||||
|  |                         success: function() { | ||||||
|  |                             // Reload the "in progress" table | ||||||
|  |                             $('#build-output-table').bootstrapTable('refresh'); | ||||||
|  |  | ||||||
|  |                             // Reload the "completed" table | ||||||
|  |                             $('#build-stock-table').bootstrapTable('refresh'); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 ); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             {% endif %} | ||||||
|  |          | ||||||
|  |             {% if build.active and build.has_untracked_bom_items %} | ||||||
|  |             // Load allocation table for un-tracked parts | ||||||
|  |             loadBuildOutputAllocationTable( | ||||||
|  |                 build_info, | ||||||
|  |                 null, | ||||||
|  |                 { | ||||||
|  |                     search: true, | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |             {% endif %} | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| ); | ); | ||||||
| {% endfor %} |  | ||||||
|  |  | ||||||
| loadBuildTable($('#sub-build-table'), { | loadBuildTable($('#sub-build-table'), { | ||||||
|     url: '{% url "api-build-list" %}', |     url: '{% url "api-build-list" %}', | ||||||
| @@ -342,6 +398,7 @@ loadBuildTable($('#sub-build-table'), { | |||||||
|     } |     } | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| enableDragAndDrop( | enableDragAndDrop( | ||||||
|     '#attachment-dropzone', |     '#attachment-dropzone', | ||||||
|     '{% url "api-build-attachment-list" %}', |     '{% url "api-build-attachment-list" %}', | ||||||
| @@ -416,11 +473,6 @@ $('#edit-notes').click(function() { | |||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| {% if build.has_untracked_bom_items %} |  | ||||||
| // Load allocation table for un-tracked parts |  | ||||||
| loadBuildOutputAllocationTable(buildInfo, null); |  | ||||||
| {% endif %} |  | ||||||
|  |  | ||||||
| function reloadTable() { | function reloadTable() { | ||||||
|     $('#allocation-table-untracked').bootstrapTable('refresh'); |     $('#allocation-table-untracked').bootstrapTable('refresh'); | ||||||
| } | } | ||||||
| @@ -471,6 +523,10 @@ $('#allocate-selected-items').click(function() { | |||||||
|  |  | ||||||
|     var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections"); |     var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections"); | ||||||
|  |  | ||||||
|  |     if (bom_items.length == 0) { | ||||||
|  |         bom_items = $("#allocation-table-untracked").bootstrapTable('getData'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     allocateStockToBuild( |     allocateStockToBuild( | ||||||
|         {{ build.pk }}, |         {{ build.pk }}, | ||||||
|         {{ build.part.pk }}, |         {{ build.part.pk }}, | ||||||
|   | |||||||
| @@ -1,10 +0,0 @@ | |||||||
| {% extends "modal_form.html" %} |  | ||||||
| {% load i18n %} |  | ||||||
|  |  | ||||||
| {% block pre_form_content %} |  | ||||||
| <div class='alert alert-block alert-info'> |  | ||||||
|     <p> |  | ||||||
|         {% trans "Alter the quantity of stock allocated to the build output" %} |  | ||||||
|     </p> |  | ||||||
| </div> |  | ||||||
| {% endblock %} |  | ||||||
| @@ -19,16 +19,25 @@ | |||||||
|     {% if build.active %} |     {% if build.active %} | ||||||
|     <li class='list-group-item' title='{% trans "Allocate Stock" %}'> |     <li class='list-group-item' title='{% trans "Allocate Stock" %}'> | ||||||
|         <a href='#' id='select-allocate' class='nav-toggle'> |         <a href='#' id='select-allocate' class='nav-toggle'> | ||||||
|             <span class='fas fa-tools sidebar-icon'></span> |             <span class='fas fa-tasks sidebar-icon'></span> | ||||||
|             {% trans "Allocate Stock" %} |             {% trans "Allocate Stock" %} | ||||||
|         </a> |         </a> | ||||||
|     </li> |     </li> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|  |  | ||||||
|     <li class='list-group-item' title='{% trans "Build Outputs" %}'> |     {% if not build.is_complete %} | ||||||
|  |     <li class='list-group-item' title='{% trans "Pending Outputs" %}'> | ||||||
|         <a href='#' id='select-outputs' class='nav-toggle'> |         <a href='#' id='select-outputs' class='nav-toggle'> | ||||||
|             <span class='fas fa-box sidebar-icon'></span> |             <span class='fas fa-tools sidebar-icon'></span> | ||||||
|             {% trans "Build Outputs" %} |             {% trans "Pending Outputs" %} | ||||||
|  |         </a> | ||||||
|  |     </li> | ||||||
|  |     {% endif %} | ||||||
|  |  | ||||||
|  |     <li class='list-group-item' title='{% trans "Completed Outputs" %}'> | ||||||
|  |         <a href='#' id='select-completed' class='nav-toggle'> | ||||||
|  |             <span class='fas fa-boxes sidebar-icon'></span> | ||||||
|  |             {% trans "Completed Outputs" %} | ||||||
|         </a> |         </a> | ||||||
|     </li> |     </li> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ from django.urls import reverse | |||||||
|  |  | ||||||
| from part.models import Part | from part.models import Part | ||||||
| from build.models import Build, BuildItem | from build.models import Build, BuildItem | ||||||
|  | from stock.models import StockItem | ||||||
|  |  | ||||||
| from InvenTree.status_codes import BuildStatus | from InvenTree.status_codes import BuildStatus | ||||||
| from InvenTree.api_tester import InvenTreeAPITestCase | from InvenTree.api_tester import InvenTreeAPITestCase | ||||||
| @@ -37,6 +38,148 @@ class BuildAPITest(InvenTreeAPITestCase): | |||||||
|         super().setUp() |         super().setUp() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BuildCompleteTest(BuildAPITest): | ||||||
|  |     """ | ||||||
|  |     Unit testing for the build complete API endpoint | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def setUp(self): | ||||||
|  |  | ||||||
|  |         super().setUp() | ||||||
|  |  | ||||||
|  |         self.build = Build.objects.get(pk=1) | ||||||
|  |  | ||||||
|  |         self.url = reverse('api-build-complete', kwargs={'pk': self.build.pk}) | ||||||
|  |  | ||||||
|  |     def test_invalid(self): | ||||||
|  |         """ | ||||||
|  |         Test with invalid data | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         # Test with an invalid build ID | ||||||
|  |         self.post( | ||||||
|  |             reverse('api-build-complete', kwargs={'pk': 99999}), | ||||||
|  |             {}, | ||||||
|  |             expected_code=400 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         data = self.post(self.url, {}, expected_code=400).data | ||||||
|  |  | ||||||
|  |         self.assertIn("This field is required", str(data['outputs'])) | ||||||
|  |         self.assertIn("This field is required", str(data['location'])) | ||||||
|  |  | ||||||
|  |         # Test with an invalid location | ||||||
|  |         data = self.post( | ||||||
|  |             self.url, | ||||||
|  |             { | ||||||
|  |                 "outputs": [], | ||||||
|  |                 "location": 999999, | ||||||
|  |             }, | ||||||
|  |             expected_code=400 | ||||||
|  |         ).data | ||||||
|  |  | ||||||
|  |         self.assertIn( | ||||||
|  |             "Invalid pk", | ||||||
|  |             str(data["location"]) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         data = self.post( | ||||||
|  |             self.url, | ||||||
|  |             { | ||||||
|  |                 "outputs": [], | ||||||
|  |                 "location": 1, | ||||||
|  |             }, | ||||||
|  |             expected_code=400 | ||||||
|  |         ).data | ||||||
|  |  | ||||||
|  |         self.assertIn("A list of build outputs must be provided", str(data)) | ||||||
|  |  | ||||||
|  |         stock_item = StockItem.objects.create( | ||||||
|  |             part=self.build.part, | ||||||
|  |             quantity=100, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         post_data = { | ||||||
|  |             "outputs": [ | ||||||
|  |                 { | ||||||
|  |                     "output": stock_item.pk, | ||||||
|  |                 }, | ||||||
|  |             ], | ||||||
|  |             "location": 1, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         # Post with a stock item that does not match the build | ||||||
|  |         data = self.post( | ||||||
|  |             self.url, | ||||||
|  |             post_data, | ||||||
|  |             expected_code=400 | ||||||
|  |         ).data | ||||||
|  |  | ||||||
|  |         self.assertIn( | ||||||
|  |             "Build output does not match the parent build", | ||||||
|  |             str(data["outputs"][0]) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Now, ensure that the stock item *does* match the build | ||||||
|  |         stock_item.build = self.build | ||||||
|  |         stock_item.save() | ||||||
|  |  | ||||||
|  |         data = self.post( | ||||||
|  |             self.url, | ||||||
|  |             post_data, | ||||||
|  |             expected_code=400, | ||||||
|  |         ).data | ||||||
|  |  | ||||||
|  |         self.assertIn( | ||||||
|  |             "This build output has already been completed", | ||||||
|  |             str(data["outputs"][0]["output"]) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_complete(self): | ||||||
|  |         """ | ||||||
|  |         Test build order completion | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         # We start without any outputs assigned against the build | ||||||
|  |         self.assertEqual(self.build.incomplete_outputs.count(), 0) | ||||||
|  |  | ||||||
|  |         # Create some more build outputs | ||||||
|  |         for ii in range(10): | ||||||
|  |             self.build.create_build_output(10) | ||||||
|  |  | ||||||
|  |         # Check that we are in a known state | ||||||
|  |         self.assertEqual(self.build.incomplete_outputs.count(), 10) | ||||||
|  |         self.assertEqual(self.build.incomplete_count, 100) | ||||||
|  |         self.assertEqual(self.build.completed, 0) | ||||||
|  |  | ||||||
|  |         # We shall complete 4 of these outputs | ||||||
|  |         outputs = self.build.incomplete_outputs[0:4] | ||||||
|  |  | ||||||
|  |         self.post( | ||||||
|  |             self.url, | ||||||
|  |             { | ||||||
|  |                 "outputs": [{"output": output.pk} for output in outputs], | ||||||
|  |                 "location": 1, | ||||||
|  |                 "status": 50,  # Item requires attention | ||||||
|  |             }, | ||||||
|  |             expected_code=201 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # There now should be 6 incomplete build outputs remaining | ||||||
|  |         self.assertEqual(self.build.incomplete_outputs.count(), 6) | ||||||
|  |  | ||||||
|  |         # And there should be 4 completed outputs | ||||||
|  |         outputs = self.build.complete_outputs | ||||||
|  |         self.assertEqual(outputs.count(), 4) | ||||||
|  |  | ||||||
|  |         for output in outputs: | ||||||
|  |             self.assertFalse(output.is_building) | ||||||
|  |             self.assertEqual(output.build, self.build) | ||||||
|  |  | ||||||
|  |         self.build.refresh_from_db() | ||||||
|  |         self.assertEqual(self.build.completed, 40) | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildAllocationTest(BuildAPITest): | class BuildAllocationTest(BuildAPITest): | ||||||
|     """ |     """ | ||||||
|     Unit tests for allocation of stock items against a build order. |     Unit tests for allocation of stock items against a build order. | ||||||
|   | |||||||
| @@ -339,11 +339,11 @@ class BuildTest(TestCase): | |||||||
|         self.assertTrue(self.build.isFullyAllocated(self.output_1)) |         self.assertTrue(self.build.isFullyAllocated(self.output_1)) | ||||||
|         self.assertTrue(self.build.isFullyAllocated(self.output_2)) |         self.assertTrue(self.build.isFullyAllocated(self.output_2)) | ||||||
|  |  | ||||||
|         self.build.completeBuildOutput(self.output_1, None) |         self.build.complete_build_output(self.output_1, None) | ||||||
|  |  | ||||||
|         self.assertFalse(self.build.can_complete) |         self.assertFalse(self.build.can_complete) | ||||||
|  |  | ||||||
|         self.build.completeBuildOutput(self.output_2, None) |         self.build.complete_build_output(self.output_2, None) | ||||||
|  |  | ||||||
|         self.assertTrue(self.build.can_complete) |         self.assertTrue(self.build.can_complete) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ from datetime import datetime, timedelta | |||||||
| from .models import Build | from .models import Build | ||||||
| from stock.models import StockItem | from stock.models import StockItem | ||||||
|  |  | ||||||
| from InvenTree.status_codes import BuildStatus, StockStatus | from InvenTree.status_codes import BuildStatus | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildTestSimple(TestCase): | class BuildTestSimple(TestCase): | ||||||
| @@ -252,53 +252,6 @@ class TestBuildViews(TestCase): | |||||||
|  |  | ||||||
|         self.assertIn(build.title, content) |         self.assertIn(build.title, content) | ||||||
|  |  | ||||||
|     def test_build_output_complete(self): |  | ||||||
|         """ |  | ||||||
|         Test the build output completion form |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         # Firstly, check that the build cannot be completed! |  | ||||||
|         self.assertFalse(self.build.can_complete) |  | ||||||
|  |  | ||||||
|         url = reverse('build-output-complete', args=(1,)) |  | ||||||
|  |  | ||||||
|         # Test without confirmation |  | ||||||
|         response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  |  | ||||||
|         data = json.loads(response.content) |  | ||||||
|         self.assertFalse(data['form_valid']) |  | ||||||
|  |  | ||||||
|         # Test with confirmation, valid location |  | ||||||
|         response = self.client.post( |  | ||||||
|             url, |  | ||||||
|             { |  | ||||||
|                 'confirm': 1, |  | ||||||
|                 'confirm_incomplete': 1, |  | ||||||
|                 'location': 1, |  | ||||||
|                 'output': self.output.pk, |  | ||||||
|                 'stock_status': StockStatus.DAMAGED |  | ||||||
|             }, |  | ||||||
|             HTTP_X_REQUESTED_WITH='XMLHttpRequest' |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  |  | ||||||
|         data = json.loads(response.content) |  | ||||||
|  |  | ||||||
|         self.assertTrue(data['form_valid']) |  | ||||||
|  |  | ||||||
|         # Now the build should be able to be completed |  | ||||||
|         self.build.refresh_from_db() |  | ||||||
|         self.assertTrue(self.build.can_complete) |  | ||||||
|  |  | ||||||
|         # Test with confirmation, invalid location |  | ||||||
|         response = self.client.post(url, {'confirm': 1, 'location': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  |  | ||||||
|         data = json.loads(response.content) |  | ||||||
|         self.assertFalse(data['form_valid']) |  | ||||||
|  |  | ||||||
|     def test_build_cancel(self): |     def test_build_cancel(self): | ||||||
|         """ Test the build cancellation form """ |         """ Test the build cancellation form """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -11,7 +11,6 @@ build_detail_urls = [ | |||||||
|     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'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), | ||||||
|     url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), |     url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), | ||||||
|     url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'), |  | ||||||
|     url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), |     url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), | ||||||
|  |  | ||||||
|     url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), |     url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), | ||||||
|   | |||||||
| @@ -12,16 +12,17 @@ from django.forms import HiddenInput | |||||||
|  |  | ||||||
| from .models import Build | from .models import Build | ||||||
| from . import forms | from . import forms | ||||||
| from stock.models import StockLocation, StockItem | from stock.models import StockItem | ||||||
|  |  | ||||||
| from InvenTree.views import AjaxUpdateView, AjaxDeleteView | from InvenTree.views import AjaxUpdateView, AjaxDeleteView | ||||||
| from InvenTree.views import InvenTreeRoleMixin | from InvenTree.views import InvenTreeRoleMixin | ||||||
| from InvenTree.helpers import str2bool, extract_serial_numbers | from InvenTree.helpers import str2bool, extract_serial_numbers | ||||||
| from InvenTree.status_codes import BuildStatus, StockStatus | from InvenTree.status_codes import BuildStatus | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildIndex(InvenTreeRoleMixin, ListView): | class BuildIndex(InvenTreeRoleMixin, ListView): | ||||||
|     """ View for displaying list of Builds |     """ | ||||||
|  |     View for displaying list of Builds | ||||||
|     """ |     """ | ||||||
|     model = Build |     model = Build | ||||||
|     template_name = 'build/index.html' |     template_name = 'build/index.html' | ||||||
| @@ -278,178 +279,10 @@ class BuildComplete(AjaxUpdateView): | |||||||
|         } |         } | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildOutputComplete(AjaxUpdateView): |  | ||||||
|     """ |  | ||||||
|     View to mark a particular build output as Complete. |  | ||||||
|  |  | ||||||
|     - Notifies the user of which parts will be removed from stock. |  | ||||||
|     - Assignes (tracked) allocated items from stock to the build output |  | ||||||
|     - Deletes pending BuildItem objects |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     model = Build |  | ||||||
|     form_class = forms.CompleteBuildOutputForm |  | ||||||
|     context_object_name = "build" |  | ||||||
|     ajax_form_title = _("Complete Build Output") |  | ||||||
|     ajax_template_name = "build/complete_output.html" |  | ||||||
|  |  | ||||||
|     def get_form(self): |  | ||||||
|  |  | ||||||
|         build = self.get_object() |  | ||||||
|  |  | ||||||
|         form = super().get_form() |  | ||||||
|  |  | ||||||
|         # Extract the build output object |  | ||||||
|         output = None |  | ||||||
|         output_id = form['output'].value() |  | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             output = StockItem.objects.get(pk=output_id) |  | ||||||
|         except (ValueError, StockItem.DoesNotExist): |  | ||||||
|             pass |  | ||||||
|  |  | ||||||
|         if output: |  | ||||||
|             if build.isFullyAllocated(output): |  | ||||||
|                 form.fields['confirm_incomplete'].widget = HiddenInput() |  | ||||||
|  |  | ||||||
|         return form |  | ||||||
|  |  | ||||||
|     def validate(self, build, form, **kwargs): |  | ||||||
|         """ |  | ||||||
|         Custom validation steps for the BuildOutputComplete" form |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         data = form.cleaned_data |  | ||||||
|  |  | ||||||
|         output = data.get('output', None) |  | ||||||
|  |  | ||||||
|         stock_status = data.get('stock_status', StockStatus.OK) |  | ||||||
|  |  | ||||||
|         # Any "invalid" stock status defaults to OK |  | ||||||
|         try: |  | ||||||
|             stock_status = int(stock_status) |  | ||||||
|         except (ValueError): |  | ||||||
|             stock_status = StockStatus.OK |  | ||||||
|  |  | ||||||
|         if int(stock_status) not in StockStatus.keys(): |  | ||||||
|             form.add_error('stock_status', _('Invalid stock status value selected')) |  | ||||||
|  |  | ||||||
|         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().get_initial() |  | ||||||
|         build = self.get_object() |  | ||||||
|  |  | ||||||
|         if build.part.default_location is not None: |  | ||||||
|             try: |  | ||||||
|                 location = StockLocation.objects.get(pk=build.part.default_location.id) |  | ||||||
|                 initials['location'] = location |  | ||||||
|             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 |  | ||||||
|  |  | ||||||
|         - Build information is required |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         build = self.get_object() |  | ||||||
|  |  | ||||||
|         context = {} |  | ||||||
|  |  | ||||||
|         # Build object |  | ||||||
|         context['build'] = build |  | ||||||
|  |  | ||||||
|         form = self.get_form() |  | ||||||
|  |  | ||||||
|         output = form['output'].value() |  | ||||||
|  |  | ||||||
|         if output: |  | ||||||
|             try: |  | ||||||
|                 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 |  | ||||||
|  |  | ||||||
|         return context |  | ||||||
|  |  | ||||||
|     def save(self, build, form, **kwargs): |  | ||||||
|  |  | ||||||
|         data = form.cleaned_data |  | ||||||
|  |  | ||||||
|         location = data.get('location', None) |  | ||||||
|         output = data.get('output', None) |  | ||||||
|         stock_status = data.get('stock_status', StockStatus.OK) |  | ||||||
|  |  | ||||||
|         # Any "invalid" stock status defaults to OK |  | ||||||
|         try: |  | ||||||
|             stock_status = int(stock_status) |  | ||||||
|         except (ValueError): |  | ||||||
|             stock_status = StockStatus.OK |  | ||||||
|  |  | ||||||
|         # Complete the build output |  | ||||||
|         build.completeBuildOutput( |  | ||||||
|             output, |  | ||||||
|             self.request.user, |  | ||||||
|             location=location, |  | ||||||
|             status=stock_status, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def get_data(self): |  | ||||||
|         """ Provide feedback data back to the form """ |  | ||||||
|         return { |  | ||||||
|             'success': _('Build output completed') |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildDetail(InvenTreeRoleMixin, DetailView): | class BuildDetail(InvenTreeRoleMixin, DetailView): | ||||||
|     """ Detail view of a single Build object. """ |     """ | ||||||
|  |     Detail view of a single Build object. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|     model = Build |     model = Build | ||||||
|     template_name = 'build/detail.html' |     template_name = 'build/detail.html' | ||||||
| @@ -477,7 +310,9 @@ class BuildDetail(InvenTreeRoleMixin, DetailView): | |||||||
|  |  | ||||||
|  |  | ||||||
| class BuildDelete(AjaxDeleteView): | class BuildDelete(AjaxDeleteView): | ||||||
|     """ View to delete a build """ |     """ | ||||||
|  |     View to delete a build | ||||||
|  |     """ | ||||||
|  |  | ||||||
|     model = Build |     model = Build | ||||||
|     ajax_template_name = 'build/delete_build.html' |     ajax_template_name = 'build/delete_build.html' | ||||||
|   | |||||||
| @@ -1045,6 +1045,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): | |||||||
|             'validator': [int, MinValueValidator(1)] |             'validator': [int, MinValueValidator(1)] | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|  |         'SEARCH_SHOW_STOCK_LEVELS': { | ||||||
|  |             'name': _('Search Show Stock'), | ||||||
|  |             'description': _('Display stock levels in search preview window'), | ||||||
|  |             'default': True, | ||||||
|  |             'validator': bool, | ||||||
|  |         }, | ||||||
|  |  | ||||||
|         'PART_SHOW_QUANTITY_IN_FORMS': { |         'PART_SHOW_QUANTITY_IN_FORMS': { | ||||||
|             'name': _('Show Quantity in Forms'), |             'name': _('Show Quantity in Forms'), | ||||||
|             'description': _('Display available part quantity in some forms'), |             'description': _('Display available part quantity in some forms'), | ||||||
|   | |||||||
| @@ -1,7 +0,0 @@ | |||||||
| {% extends "modal_delete_form.html" %} |  | ||||||
|  |  | ||||||
| {% block pre_form_content %} |  | ||||||
|  |  | ||||||
| Are you sure you wish to delete this currency? |  | ||||||
|  |  | ||||||
| {% endblock %} |  | ||||||
| @@ -5,7 +5,6 @@ JSON API for the Order app | |||||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||||
| from __future__ import unicode_literals | from __future__ import unicode_literals | ||||||
|  |  | ||||||
| from django.utils.translation import ugettext_lazy as _ |  | ||||||
| from django.conf.urls import url, include | from django.conf.urls import url, include | ||||||
| from django.db.models import Q, F | from django.db.models import Q, F | ||||||
|  |  | ||||||
| @@ -13,7 +12,6 @@ from django_filters import rest_framework as rest_filters | |||||||
| from rest_framework import generics | from rest_framework import generics | ||||||
| from rest_framework import filters, status | from rest_framework import filters, status | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ValidationError |  | ||||||
|  |  | ||||||
|  |  | ||||||
| from InvenTree.filters import InvenTreeOrderingFilter | from InvenTree.filters import InvenTreeOrderingFilter | ||||||
| @@ -236,25 +234,15 @@ class POReceive(generics.CreateAPIView): | |||||||
|         context = super().get_serializer_context() |         context = super().get_serializer_context() | ||||||
|  |  | ||||||
|         # Pass the purchase order through to the serializer for validation |         # Pass the purchase order through to the serializer for validation | ||||||
|         context['order'] = self.get_order() |         try: | ||||||
|  |             context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None)) | ||||||
|  |         except: | ||||||
|  |             pass | ||||||
|  |  | ||||||
|         context['request'] = self.request |         context['request'] = self.request | ||||||
|  |  | ||||||
|         return context |         return context | ||||||
|  |  | ||||||
|     def get_order(self): |  | ||||||
|         """ |  | ||||||
|         Returns the PurchaseOrder associated with this API endpoint |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         pk = self.kwargs.get('pk', None) |  | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             order = PurchaseOrder.objects.get(pk=pk) |  | ||||||
|         except (PurchaseOrder.DoesNotExist, ValueError): |  | ||||||
|             raise ValidationError(_("Matching purchase order does not exist")) |  | ||||||
|          |  | ||||||
|         return order |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class POLineItemFilter(rest_filters.FilterSet): | class POLineItemFilter(rest_filters.FilterSet): | ||||||
|     """ |     """ | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ from django import forms | |||||||
| from django.utils.translation import ugettext_lazy as _ | from django.utils.translation import ugettext_lazy as _ | ||||||
|  |  | ||||||
| from InvenTree.forms import HelperForm | from InvenTree.forms import HelperForm | ||||||
| from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField | from InvenTree.fields import InvenTreeMoneyField | ||||||
|  |  | ||||||
| from InvenTree.helpers import clean_decimal | from InvenTree.helpers import clean_decimal | ||||||
|  |  | ||||||
| @@ -19,7 +19,6 @@ import part.models | |||||||
|  |  | ||||||
| from .models import PurchaseOrder | from .models import PurchaseOrder | ||||||
| from .models import SalesOrder, SalesOrderLineItem | from .models import SalesOrder, SalesOrderLineItem | ||||||
| from .models import SalesOrderAllocation |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class IssuePurchaseOrderForm(HelperForm): | class IssuePurchaseOrderForm(HelperForm): | ||||||
| @@ -81,6 +80,8 @@ class AllocateSerialsToSalesOrderForm(forms.Form): | |||||||
|     """ |     """ | ||||||
|     Form for assigning stock to a sales order, |     Form for assigning stock to a sales order, | ||||||
|     by serial number lookup |     by serial number lookup | ||||||
|  |  | ||||||
|  |     TODO: Refactor this form / view to use the new API forms interface | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     line = forms.ModelChoiceField( |     line = forms.ModelChoiceField( | ||||||
| @@ -115,22 +116,6 @@ class AllocateSerialsToSalesOrderForm(forms.Form): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class EditSalesOrderAllocationForm(HelperForm): |  | ||||||
|     """ |  | ||||||
|     Form for editing a SalesOrderAllocation item |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity')) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = SalesOrderAllocation |  | ||||||
|  |  | ||||||
|         fields = [ |  | ||||||
|             'line', |  | ||||||
|             'item', |  | ||||||
|             'quantity'] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class OrderMatchItemForm(MatchItemForm): | class OrderMatchItemForm(MatchItemForm): | ||||||
|     """ Override MatchItemForm fields """ |     """ Override MatchItemForm fields """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -36,31 +36,39 @@ src="{% static 'img/blank_image.png' %}" | |||||||
| <p>{{ order.description }}{% include "clip.html"%}</p> | <p>{{ order.description }}{% include "clip.html"%}</p> | ||||||
| <div class='btn-row'> | <div class='btn-row'> | ||||||
|     <div class='btn-group action-buttons' role='group'> |     <div class='btn-group action-buttons' role='group'> | ||||||
|         <button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'> |         <!-- Printing options --> | ||||||
|             <span class='fas fa-print'></span>  |         <div class='btn-group'> | ||||||
|         </button> |             <button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> | ||||||
|         <button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'> |                 <span class='fas fa-print'></span> <span class='caret'></span> | ||||||
|             <span class='fas fa-file-download'></span> |             </button> | ||||||
|         </button> |             <ul class='dropdown-menu' role='menu'> | ||||||
|  |                 <li><a href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print purchase order report" %}</a></li> | ||||||
|  |                 <li><a href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li> | ||||||
|  |             </ul> | ||||||
|  |         </div> | ||||||
|         {% if roles.purchase_order.change %} |         {% if roles.purchase_order.change %} | ||||||
|         <button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'> |         <!-- order actions --> | ||||||
|             <span class='fas fa-edit icon-green'></span> |         <div class='btn-group'> | ||||||
|         </button> |             <button id='order-options' title='{% trans "Order actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> | ||||||
|  |                 <span class='fas fa-tools'></span> <span class='caret'></span> | ||||||
|  |             </button> | ||||||
|  |             <ul class='dropdown-menu' role='menu'> | ||||||
|  |                 <li><a href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li> | ||||||
|  |                 {% if order.can_cancel %} | ||||||
|  |                 <li><a href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li> | ||||||
|  |                 {% endif %} | ||||||
|  |             </ul> | ||||||
|  |         </div> | ||||||
|         {% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %} |         {% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %} | ||||||
|         <button type='button' class='btn btn-default' id='place-order' title='{% trans "Place order" %}'> |         <button type='button' class='btn btn-default' id='place-order' title='{% trans "Place order" %}'> | ||||||
|             <span class='fas fa-paper-plane icon-blue'></span> |             <span class='fas fa-shopping-cart icon-blue'></span> | ||||||
|         </button> |         </button> | ||||||
|         {% elif order.status == PurchaseOrderStatus.PLACED %} |         {% elif order.status == PurchaseOrderStatus.PLACED %} | ||||||
|         <button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'> |         <button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'> | ||||||
|             <span class='fas fa-sign-in-alt'></span> |             <span class='fas fa-sign-in-alt icon-blue'></span> | ||||||
|         </button> |         </button> | ||||||
|         <button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'> |         <button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'> | ||||||
|             <span class='fas fa-check-circle'></span> |             <span class='fas fa-check-circle icon-green'></span> | ||||||
|         </button> |  | ||||||
|         {% endif %} |  | ||||||
|         {% if order.can_cancel %} |  | ||||||
|         <button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'> |  | ||||||
|             <span class='fas fa-times-circle icon-red'></span> |  | ||||||
|         </button> |         </button> | ||||||
|         {% endif %} |         {% endif %} | ||||||
|         {% endif %} |         {% endif %} | ||||||
|   | |||||||
| @@ -47,30 +47,39 @@ src="{% static 'img/blank_image.png' %}" | |||||||
| <p>{{ order.description }}{% include "clip.html"%}</p> | <p>{{ order.description }}{% include "clip.html"%}</p> | ||||||
| <div class='btn-row'> | <div class='btn-row'> | ||||||
|     <div class='btn-group action-buttons'> |     <div class='btn-group action-buttons'> | ||||||
|         <button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'> |         <!-- Printing actions --> | ||||||
|             <span class='fas fa-print'></span>  |         <div class='btn-group'> | ||||||
|         </button> |             <button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> | ||||||
|         <button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'> |                 <span class='fas fa-print'></span> <span class='caret'></span> | ||||||
|             <span class='fas fa-file-download'></span> |             </button> | ||||||
|         </button> |             <ul class='dropdown-menu' role='menu'> | ||||||
|  |                 <li><a href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print sales order report" %}</a></li> | ||||||
|  |                 <li><a href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li> | ||||||
|  |                 <!-- | ||||||
|  |                 <li><a href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li> | ||||||
|  |                 --> | ||||||
|  |             </ul> | ||||||
|  |         </div> | ||||||
|         {% if roles.sales_order.change %} |         {% if roles.sales_order.change %} | ||||||
|         <button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'> |         <!-- Order actions --> | ||||||
|             <span class='fas fa-edit icon-green'></span> |         <div class='btn-group'> | ||||||
|         </button> |             <button id='order-options' title='{% trans "Order actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> | ||||||
|  |                 <span class='fas fa-tools'></span> <span class='caret'></span> | ||||||
|  |             </button> | ||||||
|  |             <ul class='dropdown-menu' role='menu'> | ||||||
|  |                 <li><a href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li> | ||||||
|  |                 {% if order.status == SalesOrderStatus.PENDING %} | ||||||
|  |                 <li><a href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li> | ||||||
|  |                 {% endif %} | ||||||
|  |             </ul> | ||||||
|  |  | ||||||
|  |         </div> | ||||||
|         {% if order.status == SalesOrderStatus.PENDING %} |         {% if order.status == SalesOrderStatus.PENDING %} | ||||||
|         <button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'> |         <button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'> | ||||||
|             <span class='fas fa-paper-plane icon-blue'></span> |             <span class='fas fa-truck icon-blue'></span> | ||||||
|         </button> |  | ||||||
|         <button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'> |  | ||||||
|             <span class='fas fa-times-circle icon-red'></span> |  | ||||||
|         </button> |         </button> | ||||||
|         {% endif %} |         {% endif %} | ||||||
|         {% endif %} |         {% endif %} | ||||||
|         <!-- |  | ||||||
|         <button type='button' disabled='' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'> |  | ||||||
|             <span class='fas fa-clipboard-list'></span> |  | ||||||
|         </button> |  | ||||||
|         --> |  | ||||||
|     </div> |     </div> | ||||||
| </div> | </div> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -203,6 +203,23 @@ class PurchaseOrderTest(OrderTest): | |||||||
|         # And if we try to access the detail view again, it has gone |         # And if we try to access the detail view again, it has gone | ||||||
|         response = self.get(url, expected_code=404) |         response = self.get(url, expected_code=404) | ||||||
|  |  | ||||||
|  |     def test_po_create(self): | ||||||
|  |         """ | ||||||
|  |         Test that we can create a new PurchaseOrder via the API | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         self.assignRole('purchase_order.add') | ||||||
|  |  | ||||||
|  |         self.post( | ||||||
|  |             reverse('api-po-list'), | ||||||
|  |             { | ||||||
|  |                 'reference': '12345678', | ||||||
|  |                 'supplier': 1, | ||||||
|  |                 'description': 'A test purchase order', | ||||||
|  |             }, | ||||||
|  |             expected_code=201 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PurchaseOrderReceiveTest(OrderTest): | class PurchaseOrderReceiveTest(OrderTest): | ||||||
|     """ |     """ | ||||||
| @@ -607,3 +624,20 @@ class SalesOrderTest(OrderTest): | |||||||
|  |  | ||||||
|         # And the resource should no longer be available |         # And the resource should no longer be available | ||||||
|         response = self.get(url, expected_code=404) |         response = self.get(url, expected_code=404) | ||||||
|  |  | ||||||
|  |     def test_so_create(self): | ||||||
|  |         """ | ||||||
|  |         Test that we can create a new SalesOrder via the API | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         self.assignRole('sales_order.add') | ||||||
|  |  | ||||||
|  |         self.post( | ||||||
|  |             reverse('api-so-list'), | ||||||
|  |             { | ||||||
|  |                 'reference': '1234566778', | ||||||
|  |                 'customer': 4, | ||||||
|  |                 'description': 'A test sales order', | ||||||
|  |             }, | ||||||
|  |             expected_code=201 | ||||||
|  |         ) | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ import common.models | |||||||
| from common.forms import MatchItemForm | from common.forms import MatchItemForm | ||||||
|  |  | ||||||
| from .models import Part, PartCategory, PartRelated | from .models import Part, PartCategory, PartRelated | ||||||
| from .models import PartParameterTemplate, PartParameter | from .models import PartParameterTemplate | ||||||
| from .models import PartCategoryParameterTemplate | from .models import PartCategoryParameterTemplate | ||||||
| from .models import PartSellPriceBreak, PartInternalPriceBreak | from .models import PartSellPriceBreak, PartInternalPriceBreak | ||||||
|  |  | ||||||
| @@ -188,18 +188,6 @@ class EditPartParameterTemplateForm(HelperForm): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class EditPartParameterForm(HelperForm): |  | ||||||
|     """ Form for editing a PartParameter object """ |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = PartParameter |  | ||||||
|         fields = [ |  | ||||||
|             'part', |  | ||||||
|             'template', |  | ||||||
|             'data' |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class EditCategoryForm(HelperForm): | class EditCategoryForm(HelperForm): | ||||||
|     """ Form for editing a PartCategory object """ |     """ Form for editing a PartCategory object """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -16,8 +16,6 @@ from InvenTree.forms import HelperForm | |||||||
| from InvenTree.fields import RoundingDecimalFormField | from InvenTree.fields import RoundingDecimalFormField | ||||||
| from InvenTree.fields import DatePickerFormField | from InvenTree.fields import DatePickerFormField | ||||||
|  |  | ||||||
| from report.models import TestReport |  | ||||||
|  |  | ||||||
| from part.models import Part | from part.models import Part | ||||||
|  |  | ||||||
| from .models import StockLocation, StockItem, StockItemTracking | from .models import StockLocation, StockItem, StockItemTracking | ||||||
| @@ -26,6 +24,8 @@ from .models import StockLocation, StockItem, StockItemTracking | |||||||
| class AssignStockItemToCustomerForm(HelperForm): | class AssignStockItemToCustomerForm(HelperForm): | ||||||
|     """ |     """ | ||||||
|     Form for manually assigning a StockItem to a Customer |     Form for manually assigning a StockItem to a Customer | ||||||
|  |  | ||||||
|  |     TODO: This could be a simple API driven form! | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
| @@ -38,6 +38,8 @@ class AssignStockItemToCustomerForm(HelperForm): | |||||||
| class ReturnStockItemForm(HelperForm): | class ReturnStockItemForm(HelperForm): | ||||||
|     """ |     """ | ||||||
|     Form for manually returning a StockItem into stock |     Form for manually returning a StockItem into stock | ||||||
|  |  | ||||||
|  |     TODO: This could be a simple API driven form! | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
| @@ -48,7 +50,11 @@ class ReturnStockItemForm(HelperForm): | |||||||
|  |  | ||||||
|  |  | ||||||
| class EditStockLocationForm(HelperForm): | class EditStockLocationForm(HelperForm): | ||||||
|     """ Form for editing a StockLocation """ |     """ | ||||||
|  |     Form for editing a StockLocation | ||||||
|  |  | ||||||
|  |     TODO: Migrate this form to the modern API forms interface | ||||||
|  |     """ | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = StockLocation |         model = StockLocation | ||||||
| @@ -63,6 +69,8 @@ class EditStockLocationForm(HelperForm): | |||||||
| class ConvertStockItemForm(HelperForm): | class ConvertStockItemForm(HelperForm): | ||||||
|     """ |     """ | ||||||
|     Form for converting a StockItem to a variant of its current part. |     Form for converting a StockItem to a variant of its current part. | ||||||
|  |  | ||||||
|  |     TODO: Migrate this form to the modern API forms interface | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
| @@ -73,7 +81,11 @@ class ConvertStockItemForm(HelperForm): | |||||||
|  |  | ||||||
|  |  | ||||||
| class CreateStockItemForm(HelperForm): | class CreateStockItemForm(HelperForm): | ||||||
|     """ Form for creating a new StockItem """ |     """ | ||||||
|  |     Form for creating a new StockItem | ||||||
|  |      | ||||||
|  |     TODO: Migrate this form to the modern API forms interface | ||||||
|  |     """ | ||||||
|  |  | ||||||
|     expiry_date = DatePickerFormField( |     expiry_date = DatePickerFormField( | ||||||
|         label=_('Expiry Date'), |         label=_('Expiry Date'), | ||||||
| @@ -129,7 +141,11 @@ class CreateStockItemForm(HelperForm): | |||||||
|  |  | ||||||
|  |  | ||||||
| class SerializeStockForm(HelperForm): | class SerializeStockForm(HelperForm): | ||||||
|     """ Form for serializing a StockItem. """ |     """ | ||||||
|  |     Form for serializing a StockItem. | ||||||
|  |      | ||||||
|  |     TODO: Migrate this form to the modern API forms interface | ||||||
|  |     """ | ||||||
|  |  | ||||||
|     destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination for serialized stock (by default, will remain in current location)')) |     destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination for serialized stock (by default, will remain in current location)')) | ||||||
|  |  | ||||||
| @@ -160,73 +176,11 @@ class SerializeStockForm(HelperForm): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockItemLabelSelectForm(HelperForm): |  | ||||||
|     """ Form for selecting a label template for a StockItem """ |  | ||||||
|  |  | ||||||
|     label = forms.ChoiceField( |  | ||||||
|         label=_('Label'), |  | ||||||
|         help_text=_('Select test report template') |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = StockItem |  | ||||||
|         fields = [ |  | ||||||
|             'label', |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|     def get_label_choices(self, labels): |  | ||||||
|  |  | ||||||
|         choices = [] |  | ||||||
|  |  | ||||||
|         if len(labels) > 0: |  | ||||||
|             for label in labels: |  | ||||||
|                 choices.append((label.pk, label)) |  | ||||||
|  |  | ||||||
|         return choices |  | ||||||
|  |  | ||||||
|     def __init__(self, labels, *args, **kwargs): |  | ||||||
|  |  | ||||||
|         super().__init__(*args, **kwargs) |  | ||||||
|  |  | ||||||
|         self.fields['label'].choices = self.get_label_choices(labels) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestReportFormatForm(HelperForm): |  | ||||||
|     """ Form for selection a test report template """ |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = StockItem |  | ||||||
|         fields = [ |  | ||||||
|             'template', |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|     def __init__(self, stock_item, *args, **kwargs): |  | ||||||
|         self.stock_item = stock_item |  | ||||||
|  |  | ||||||
|         super().__init__(*args, **kwargs) |  | ||||||
|         self.fields['template'].choices = self.get_template_choices() |  | ||||||
|  |  | ||||||
|     def get_template_choices(self): |  | ||||||
|         """ |  | ||||||
|         Generate a list of of TestReport options for the StockItem |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         choices = [] |  | ||||||
|  |  | ||||||
|         templates = TestReport.objects.filter(enabled=True) |  | ||||||
|  |  | ||||||
|         for template in templates: |  | ||||||
|             if template.enabled and template.matches_stock_item(self.stock_item): |  | ||||||
|                 choices.append((template.pk, template)) |  | ||||||
|  |  | ||||||
|         return choices |  | ||||||
|  |  | ||||||
|     template = forms.ChoiceField(label=_('Template'), help_text=_('Select test report template')) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class InstallStockForm(HelperForm): | class InstallStockForm(HelperForm): | ||||||
|     """ |     """ | ||||||
|     Form for manually installing a stock item into another stock item |     Form for manually installing a stock item into another stock item | ||||||
|  |  | ||||||
|  |     TODO: Migrate this form to the modern API forms interface | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     part = forms.ModelChoiceField( |     part = forms.ModelChoiceField( | ||||||
| @@ -275,6 +229,8 @@ class InstallStockForm(HelperForm): | |||||||
| class UninstallStockForm(forms.ModelForm): | class UninstallStockForm(forms.ModelForm): | ||||||
|     """ |     """ | ||||||
|     Form for uninstalling a stock item which is installed in another item. |     Form for uninstalling a stock item which is installed in another item. | ||||||
|  |  | ||||||
|  |     TODO: Migrate this form to the modern API forms interface | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items')) |     location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items')) | ||||||
| @@ -301,6 +257,8 @@ class EditStockItemForm(HelperForm): | |||||||
|     location - Must be updated in a 'move' transaction |     location - Must be updated in a 'move' transaction | ||||||
|     quantity - Must be updated in a 'stocktake' transaction |     quantity - Must be updated in a 'stocktake' transaction | ||||||
|     part - Cannot be edited after creation |     part - Cannot be edited after creation | ||||||
|  |  | ||||||
|  |     TODO: Migrate this form to the modern API forms interface | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     expiry_date = DatePickerFormField( |     expiry_date = DatePickerFormField( | ||||||
|   | |||||||
| @@ -1,50 +0,0 @@ | |||||||
| {% load i18n %} |  | ||||||
| {% load inventree_extras %} |  | ||||||
|  |  | ||||||
| {% block pre_form_content %} |  | ||||||
|  |  | ||||||
| {% endblock %} |  | ||||||
|  |  | ||||||
| <form method="post" action='' class='js-modal-form' enctype="multipart/form-data"> |  | ||||||
|   {% csrf_token %} |  | ||||||
|   {% load crispy_forms_tags %} |  | ||||||
|  |  | ||||||
|   <input type='hidden' name='stock_action' value='{{ stock_action }}'/> |  | ||||||
|  |  | ||||||
|   <table class='table table-condensed table-striped' id='stock-table'> |  | ||||||
|       <tr> |  | ||||||
|           <th>{% trans "Stock Item" %}</th> |  | ||||||
|           <th>{% trans "Location" %}</th> |  | ||||||
|           <th>{% trans "Quantity" %}</th> |  | ||||||
|           {% if edit_quantity %} |  | ||||||
|           <th>{{ stock_action_title }}</th> |  | ||||||
|           {% endif %} |  | ||||||
|           <th></th> |  | ||||||
|       </tr> |  | ||||||
|       {% for item in stock_items %} |  | ||||||
|       <tr id='stock-row-{{ item.id }}' class='error'> |  | ||||||
|           <td>{% include "hover_image.html" with image=item.part.image hover=True %} |  | ||||||
|             {{ item.part.full_name }} <small><em>{{ item.part.description }}</em></small></td>  |  | ||||||
|           <td>{{ item.location.pathstring }}</td>  |  | ||||||
|           <td>{% decimal item.quantity %}</td> |  | ||||||
|           <td> |  | ||||||
|             {% if edit_quantity %} |  | ||||||
|             <input class='numberinput' |  | ||||||
|               min='0' |  | ||||||
|               {% if stock_action == 'take' or stock_action == 'move' %} max='{{ item.quantity }}' {% endif %} |  | ||||||
|               value='{% decimal item.new_quantity %}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/> |  | ||||||
|             {% if item.error %} |  | ||||||
|             <br><span class='help-inline'>{{ item.error }}</span> |  | ||||||
|             {% endif %} |  | ||||||
|             {% else %} |  | ||||||
|             <input type='hidden' name='stock-id-{{ item.id }}' value='{{ item.new_quantity }}'/> |  | ||||||
|             {% endif %}   |  | ||||||
|           </td> |  | ||||||
|           <td><button class='btn btn-default btn-remove' onclick='removeStockRow()' id='del-{{ item.id }}' title='{% trans "Remove item" %}' type='button'><span row='stock-row-{{ item.id }}' class='fas fa-trash-alt icon-red'></span></button></td> |  | ||||||
|       </tr> |  | ||||||
|       {% endfor %} |  | ||||||
|     </table> |  | ||||||
|  |  | ||||||
|   {% crispy form %} |  | ||||||
|  |  | ||||||
| </form> |  | ||||||
| @@ -1 +0,0 @@ | |||||||
| {% extends "modal_form.html" %} |  | ||||||
| @@ -12,12 +12,14 @@ | |||||||
| <hr> | <hr> | ||||||
|  |  | ||||||
| <div class='col-sm-3' id='item-panel'> | <div class='col-sm-3' id='item-panel'> | ||||||
|     <ul class='list-group' id='action-item-list'> |     <div class='panel panel-default panel-inventree'> | ||||||
|     </ul> |         <ul class='list-group' id='action-item-list'> | ||||||
|  |         </ul> | ||||||
|  |     </div> | ||||||
| </div> | </div> | ||||||
| <div class='col-sm-9' id='details-panel'> | <div class='col-sm-9' id='details-panel'> | ||||||
|     <ul class='list-group' id='detail-item-list'> |     <ul class='list-group' id='detail-item-list'> | ||||||
|         <li class='list-group-item'> |         <li class='list-group-item panel panel-default panel-inventree'> | ||||||
|             <div class='container'> |             <div class='container'> | ||||||
|                 <img class='index-bg' src='{% static "img/inventree.png" %}'> |                 <img class='index-bg' src='{% static "img/inventree.png" %}'> | ||||||
|             </div> |             </div> | ||||||
| @@ -54,7 +56,7 @@ function addHeaderAction(label, title, icon, options) { | |||||||
|  |  | ||||||
|     // Add a detail item to the detail item-panel |     // Add a detail item to the detail item-panel | ||||||
|     $("#detail-item-list").append( |     $("#detail-item-list").append( | ||||||
|         `<li class='list-group-item' id='detail-${label}'> |         `<li class='list-group-item panel panel-default panel-inventree' id='detail-${label}'> | ||||||
|             <h4>${title}</h4> |             <h4>${title}</h4> | ||||||
|             <table class='table table-condensed table-striped' id='table-${label}'></table> |             <table class='table table-condensed table-striped' id='table-${label}'></table> | ||||||
|         </li>` |         </li>` | ||||||
|   | |||||||
| @@ -26,12 +26,14 @@ | |||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
| <div class='col-sm-3' id='item-panel'> | <div class='col-sm-3' id='item-panel'> | ||||||
|     <ul class='list-group' id='search-item-list'> |     <div class='panel panel-default panel-inventree'> | ||||||
|     </ul> |         <ul class='list-group' id='search-item-list'> | ||||||
|  |         </ul> | ||||||
|  |     </div> | ||||||
| </div> | </div> | ||||||
| <div class='col-sm-9' id='details-panel'> | <div class='col-sm-9' id='details-panel'> | ||||||
|     <ul class='list-group' id='search-result-list'> |     <ul class='list-group' id='search-result-list'> | ||||||
|         <li class='list-group-item'> |         <li class='list-group-item panel panel-default panel-inventree'> | ||||||
|             <div class='container'> |             <div class='container'> | ||||||
|                 <img class='index-bg' src='{% static "img/inventree.png" %}'> |                 <img class='index-bg' src='{% static "img/inventree.png" %}'> | ||||||
|             </div> |             </div> | ||||||
| @@ -67,7 +69,7 @@ | |||||||
|  |  | ||||||
|         // Add a results table |         // Add a results table | ||||||
|         $('#search-result-list').append( |         $('#search-result-list').append( | ||||||
|             `<li class='list-group-item' id='search-result-${label}'> |             `<li class='list-group-item panel panel-default panel-inventree' id='search-result-${label}'> | ||||||
|                 <h4>${title}</h4> |                 <h4>${title}</h4> | ||||||
|                 <table class='table table-condensed table-striped' id='table-${label}'></table> |                 <table class='table table-condensed table-striped' id='table-${label}'></table> | ||||||
|             </li>` |             </li>` | ||||||
|   | |||||||
| @@ -10,12 +10,12 @@ | |||||||
|     </li> |     </li> | ||||||
|  |  | ||||||
|     <li class='list-group-item'> |     <li class='list-group-item'> | ||||||
|         <strong>{% trans "User Settings" %}</strong> |         <span class='fas fa-user'></span> <strong>{% trans "User Settings" %}</strong> | ||||||
|     </li> |     </li> | ||||||
|  |  | ||||||
|     <li class='list-group-item' title='{% trans "Account" %}'> |     <li class='list-group-item' title='{% trans "Account" %}'> | ||||||
|         <a href='#' class='nav-toggle' id='select-account'> |         <a href='#' class='nav-toggle' id='select-account'> | ||||||
|             <span class='fas fa-user'></span> {% trans "Account" %} |             <span class='fas fa-user-cog'></span> {% trans "Account" %} | ||||||
|         </a> |         </a> | ||||||
|     </li> |     </li> | ||||||
|  |  | ||||||
| @@ -60,7 +60,7 @@ | |||||||
|     {% if user.is_staff %} |     {% if user.is_staff %} | ||||||
|  |  | ||||||
|     <li class='list-group-item'> |     <li class='list-group-item'> | ||||||
|         <strong>{% trans "InvenTree Settings" %}</strong> |         <span class='fas fa-cogs'></span> <strong>{% trans "Global Settings" %}</strong> | ||||||
|     </li> |     </li> | ||||||
|  |  | ||||||
|     <li class='list-group-item' title='{% trans "Server" %}'> |     <li class='list-group-item' title='{% trans "Server" %}'> | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ | |||||||
|         {% include "InvenTree/settings/header.html" %} |         {% include "InvenTree/settings/header.html" %} | ||||||
|         <tbody> |         <tbody> | ||||||
|             {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %} |             {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %} | ||||||
|  |             {% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %} | ||||||
|         </tbody> |         </tbody> | ||||||
|     </table> |     </table> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,7 +0,0 @@ | |||||||
| {% extends "modal_delete_form.html" %} |  | ||||||
| {% load i18n %} |  | ||||||
|  |  | ||||||
| {% block pre_form_content %} |  | ||||||
| {% trans "Are you sure you want to delete this attachment?" %} |  | ||||||
| <br> |  | ||||||
| {% endblock %} |  | ||||||
| @@ -143,7 +143,6 @@ | |||||||
|  |  | ||||||
| <!-- general InvenTree --> | <!-- general InvenTree --> | ||||||
| <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> | <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> | ||||||
| <script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script> |  | ||||||
|  |  | ||||||
| <!-- dynamic javascript templates --> | <!-- dynamic javascript templates --> | ||||||
| <script type='text/javascript' src="{% url 'inventree.js' %}"></script> | <script type='text/javascript' src="{% url 'inventree.js' %}"></script> | ||||||
|   | |||||||
| @@ -1,23 +0,0 @@ | |||||||
| {% block collapse_preamble %} |  | ||||||
| {% endblock %} |  | ||||||
| <div class='panel-group'> |  | ||||||
|     <div class='panel panel-default'> |  | ||||||
|         <div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}> |  | ||||||
|             <div class='row'> |  | ||||||
|                 <div class='col-sm-6'> |  | ||||||
|                     <div class='panel-title'> |  | ||||||
|                         <a data-toggle='collapse' href="#collapse-item-{{ collapse_id }}">{% block collapse_title %}Title{% endblock %}</a> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|                 {% block collapse_heading %} |  | ||||||
|                 {% endblock %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|         <div class='panel-collapse collapse' id='collapse-item-{{ collapse_id }}'> |  | ||||||
|             <div class='panel-body'> |  | ||||||
|                 {% block collapse_content %} |  | ||||||
|                 {% endblock %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     </div> |  | ||||||
| </div> |  | ||||||
| @@ -1,19 +0,0 @@ | |||||||
| {% block collapse_preamble %} |  | ||||||
| {% endblock %} |  | ||||||
| <div class='panel-group panel-index'> |  | ||||||
|     <div class='panel panel-default'> |  | ||||||
|         <div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}> |  | ||||||
|             <div class='panel-title'> |  | ||||||
|                 <a data-toggle='collapse' href="#collapse-item-{{ collapse_id }}">{% block collapse_title %}Title{% endblock %}</a> |  | ||||||
|             </div> |  | ||||||
|         {% block collapse_heading %} |  | ||||||
|         {% endblock %} |  | ||||||
|         </div> |  | ||||||
|         <div class='panel-collapse collapse' id='collapse-item-{{ collapse_id }}'> |  | ||||||
|             <div class='panel-body'> |  | ||||||
|                 {% block collapse_content %} |  | ||||||
|                 {% endblock %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     </div> |  | ||||||
| </div> |  | ||||||
| @@ -140,11 +140,13 @@ function inventreeDocReady() { | |||||||
|                     offset: 0 |                     offset: 0 | ||||||
|                 }, |                 }, | ||||||
|                 success: function(data) { |                 success: function(data) { | ||||||
|  |  | ||||||
|                     var transformed = $.map(data.results, function(el) { |                     var transformed = $.map(data.results, function(el) { | ||||||
|                         return { |                         return { | ||||||
|                             label: el.full_name, |                             label: el.full_name, | ||||||
|                             id: el.pk, |                             id: el.pk, | ||||||
|                             thumbnail: el.thumbnail |                             thumbnail: el.thumbnail, | ||||||
|  |                             data: el, | ||||||
|                         }; |                         }; | ||||||
|                     }); |                     }); | ||||||
|                     response(transformed); |                     response(transformed); | ||||||
| @@ -164,7 +166,18 @@ function inventreeDocReady() { | |||||||
|                 html += `'> `; |                 html += `'> `; | ||||||
|                 html += item.label; |                 html += item.label; | ||||||
|  |  | ||||||
|                 html += '</span></a>'; |                 html += '</span>'; | ||||||
|  |                  | ||||||
|  |                 if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { | ||||||
|  |                     html += partStockLabel( | ||||||
|  |                         item.data, | ||||||
|  |                         { | ||||||
|  |                             label_class: 'label-right', | ||||||
|  |                         } | ||||||
|  |                     ); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 html += '</a>'; | ||||||
|  |  | ||||||
|                 return $('<li>').append(html).appendTo(ul); |                 return $('<li>').append(html).appendTo(ul); | ||||||
|             }; |             }; | ||||||
| @@ -290,3 +303,8 @@ function loadBrandIcon(element, name) { | |||||||
|         element.addClass('fab fa-' + name); |         element.addClass('fab fa-' + name); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Convenience function to determine if an element exists | ||||||
|  | $.fn.exists = function() { | ||||||
|  |     return this.length !== 0; | ||||||
|  | }; | ||||||
|   | |||||||
| @@ -3,6 +3,9 @@ | |||||||
|  |  | ||||||
| /* exported | /* exported | ||||||
|     attachNavCallbacks, |     attachNavCallbacks, | ||||||
|  |     enableNavbar, | ||||||
|  |     initNavTree, | ||||||
|  |     loadTree, | ||||||
|     onPanelLoad, |     onPanelLoad, | ||||||
| */ | */ | ||||||
|  |  | ||||||
| @@ -113,3 +116,253 @@ function onPanelLoad(panel, callback) { | |||||||
|  |  | ||||||
|     }); |     }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function loadTree(url, tree, options={}) { | ||||||
|  |     /* Load the side-nav tree view | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         url: URL to request tree data | ||||||
|  |         tree: html ref to treeview | ||||||
|  |         options: | ||||||
|  |             data: data object to pass to the AJAX request | ||||||
|  |             selected: ID of currently selected item | ||||||
|  |             name: name of the tree | ||||||
|  |     */ | ||||||
|  |  | ||||||
|  |     var data = {}; | ||||||
|  |  | ||||||
|  |     if (options.data) { | ||||||
|  |         data = options.data; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var key = 'inventree-sidenav-items-'; | ||||||
|  |  | ||||||
|  |     if (options.name) { | ||||||
|  |         key += options.name; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     $.ajax({ | ||||||
|  |         url: url, | ||||||
|  |         type: 'get', | ||||||
|  |         dataType: 'json', | ||||||
|  |         data: data, | ||||||
|  |         success: function(response) { | ||||||
|  |             if (response.tree) { | ||||||
|  |                 $(tree).treeview({ | ||||||
|  |                     data: response.tree, | ||||||
|  |                     enableLinks: true, | ||||||
|  |                     showTags: true, | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |                 if (localStorage.getItem(key)) { | ||||||
|  |                     var saved_exp = localStorage.getItem(key).split(','); | ||||||
|  |  | ||||||
|  |                     // Automatically expand the desired notes | ||||||
|  |                     for (var q = 0; q < saved_exp.length; q++) { | ||||||
|  |                         $(tree).treeview('expandNode', parseInt(saved_exp[q])); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Setup a callback whenever a node is toggled | ||||||
|  |                 $(tree).on('nodeExpanded nodeCollapsed', function(event, data) { | ||||||
|  |                      | ||||||
|  |                     // Record the entire list of expanded items | ||||||
|  |                     var expanded = $(tree).treeview('getExpanded'); | ||||||
|  |  | ||||||
|  |                     var exp = []; | ||||||
|  |  | ||||||
|  |                     for (var i = 0; i < expanded.length; i++) { | ||||||
|  |                         exp.push(expanded[i].nodeId); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     // Save the expanded nodes | ||||||
|  |                     localStorage.setItem(key, exp); | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         error: function(xhr, ajaxOptions, thrownError) { | ||||||
|  |             // TODO | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initialize navigation tree display | ||||||
|  |  */ | ||||||
|  | function initNavTree(options) { | ||||||
|  |  | ||||||
|  |     var resize = true; | ||||||
|  |  | ||||||
|  |     if ('resize' in options) { | ||||||
|  |         resize = options.resize; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var label = options.label || 'nav'; | ||||||
|  |  | ||||||
|  |     var stateLabel = `${label}-tree-state`; | ||||||
|  |     var widthLabel = `${label}-tree-width`; | ||||||
|  |  | ||||||
|  |     var treeId = options.treeId || '#sidenav-left'; | ||||||
|  |     var toggleId = options.toggleId; | ||||||
|  |  | ||||||
|  |     // Initially hide the tree | ||||||
|  |     $(treeId).animate({ | ||||||
|  |         width: '0px', | ||||||
|  |     }, 0, function() { | ||||||
|  |  | ||||||
|  |         if (resize) { | ||||||
|  |             $(treeId).resizable({ | ||||||
|  |                 minWidth: '0px', | ||||||
|  |                 maxWidth: '500px', | ||||||
|  |                 handles: 'e, se', | ||||||
|  |                 grid: [5, 5], | ||||||
|  |                 stop: function(event, ui) { | ||||||
|  |                     var width = Math.round(ui.element.width()); | ||||||
|  |  | ||||||
|  |                     if (width < 75) { | ||||||
|  |                         $(treeId).animate({ | ||||||
|  |                             width: '0px' | ||||||
|  |                         }, 50); | ||||||
|  |  | ||||||
|  |                         localStorage.setItem(stateLabel, 'closed'); | ||||||
|  |                     } else { | ||||||
|  |                         localStorage.setItem(stateLabel, 'open'); | ||||||
|  |                         localStorage.setItem(widthLabel, `${width}px`); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var state = localStorage.getItem(stateLabel); | ||||||
|  |         var width = localStorage.getItem(widthLabel) || '300px'; | ||||||
|  |  | ||||||
|  |         if (state && state == 'open') { | ||||||
|  |  | ||||||
|  |             $(treeId).animate({ | ||||||
|  |                 width: width, | ||||||
|  |             }, 50); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // Register callback for 'toggle' button | ||||||
|  |     if (toggleId) { | ||||||
|  |          | ||||||
|  |         $(toggleId).click(function() { | ||||||
|  |  | ||||||
|  |             var state = localStorage.getItem(stateLabel) || 'closed'; | ||||||
|  |             var width = localStorage.getItem(widthLabel) || '300px'; | ||||||
|  |  | ||||||
|  |             if (state == 'open') { | ||||||
|  |                 $(treeId).animate({ | ||||||
|  |                     width: '0px' | ||||||
|  |                 }, 50); | ||||||
|  |  | ||||||
|  |                 localStorage.setItem(stateLabel, 'closed'); | ||||||
|  |             } else { | ||||||
|  |                 $(treeId).animate({ | ||||||
|  |                     width: width, | ||||||
|  |                 }, 50); | ||||||
|  |  | ||||||
|  |                 localStorage.setItem(stateLabel, 'open'); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Handle left-hand icon menubar display | ||||||
|  |  */ | ||||||
|  | function enableNavbar(options) { | ||||||
|  |  | ||||||
|  |     var resize = true; | ||||||
|  |  | ||||||
|  |     if ('resize' in options) { | ||||||
|  |         resize = options.resize; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var label = options.label || 'nav'; | ||||||
|  |  | ||||||
|  |     label = `navbar-${label}`; | ||||||
|  |  | ||||||
|  |     var stateLabel = `${label}-state`; | ||||||
|  |     var widthLabel = `${label}-width`; | ||||||
|  |  | ||||||
|  |     var navId = options.navId || '#sidenav-right'; | ||||||
|  |  | ||||||
|  |     var toggleId = options.toggleId; | ||||||
|  |  | ||||||
|  |     // Extract the saved width for this element | ||||||
|  |     $(navId).animate({ | ||||||
|  |         'width': '45px', | ||||||
|  |         'min-width': '45px', | ||||||
|  |         'display': 'block', | ||||||
|  |     }, 50, function() { | ||||||
|  |  | ||||||
|  |         // Make the navbar resizable | ||||||
|  |         if (resize) { | ||||||
|  |             $(navId).resizable({ | ||||||
|  |                 minWidth: options.minWidth || '100px', | ||||||
|  |                 maxWidth: options.maxWidth || '500px', | ||||||
|  |                 handles: 'e, se', | ||||||
|  |                 grid: [5, 5], | ||||||
|  |                 stop: function(event, ui) { | ||||||
|  |                     // Record the new width | ||||||
|  |                     var width = Math.round(ui.element.width()); | ||||||
|  |  | ||||||
|  |                     // Reasonably narrow? Just close it! | ||||||
|  |                     if (width <= 75) { | ||||||
|  |                         $(navId).animate({ | ||||||
|  |                             width: '45px' | ||||||
|  |                         }, 50); | ||||||
|  |  | ||||||
|  |                         localStorage.setItem(stateLabel, 'closed'); | ||||||
|  |                     } else { | ||||||
|  |                         localStorage.setItem(widthLabel, `${width}px`); | ||||||
|  |                         localStorage.setItem(stateLabel, 'open'); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var state = localStorage.getItem(stateLabel); | ||||||
|  |  | ||||||
|  |         var width = localStorage.getItem(widthLabel) || '250px'; | ||||||
|  |          | ||||||
|  |         if (state && state == 'open') { | ||||||
|  |  | ||||||
|  |             $(navId).animate({ | ||||||
|  |                 width: width | ||||||
|  |             }, 100); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // Register callback for 'toggle' button | ||||||
|  |     if (toggleId) { | ||||||
|  |  | ||||||
|  |         $(toggleId).click(function() { | ||||||
|  |  | ||||||
|  |             var state = localStorage.getItem(stateLabel) || 'closed'; | ||||||
|  |             var width = localStorage.getItem(widthLabel) || '250px'; | ||||||
|  |  | ||||||
|  |             if (state == 'open') { | ||||||
|  |                 $(navId).animate({ | ||||||
|  |                     width: '45px', | ||||||
|  |                     minWidth: '45px', | ||||||
|  |                 }, 50); | ||||||
|  |  | ||||||
|  |                 localStorage.setItem(stateLabel, 'closed'); | ||||||
|  |  | ||||||
|  |             } else { | ||||||
|  |  | ||||||
|  |                 $(navId).animate({ | ||||||
|  |                     'width': width | ||||||
|  |                 }, 50); | ||||||
|  |  | ||||||
|  |                 localStorage.setItem(stateLabel, 'open'); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -24,7 +24,7 @@ | |||||||
|     loadAllocationTable, |     loadAllocationTable, | ||||||
|     loadBuildOrderAllocationTable, |     loadBuildOrderAllocationTable, | ||||||
|     loadBuildOutputAllocationTable, |     loadBuildOutputAllocationTable, | ||||||
|     loadBuildPartsTable, |     loadBuildOutputTable, | ||||||
|     loadBuildTable, |     loadBuildTable, | ||||||
| */ | */ | ||||||
|  |  | ||||||
| @@ -108,126 +108,56 @@ function newBuildOrder(options={}) { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| function makeBuildOutputActionButtons(output, buildInfo, lines) { | /* | ||||||
|     /* Generate action buttons for a build output. |  * Construct a set of output buttons for a particular build output | ||||||
|      */ |  */ | ||||||
|  | function makeBuildOutputButtons(output_id, build_info, options={}) { | ||||||
|     var buildId = buildInfo.pk; |   | ||||||
|     var partId = buildInfo.part; |  | ||||||
|  |  | ||||||
|     var outputId = 'untracked'; |  | ||||||
|  |  | ||||||
|     if (output) { |  | ||||||
|         outputId = output.pk; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     var panel = `#allocation-panel-${outputId}`; |  | ||||||
|  |  | ||||||
|     function reloadTable() { |  | ||||||
|         $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('refresh'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Find the div where the buttons will be displayed |  | ||||||
|     var buildActions = $(panel).find(`#output-actions-${outputId}`); |  | ||||||
|  |  | ||||||
|     var html = `<div class='btn-group float-right' role='group'>`; |     var html = `<div class='btn-group float-right' role='group'>`; | ||||||
|  |  | ||||||
|     if (lines > 0) { |     // Tracked parts? Must be individually allocated | ||||||
|         html += makeIconButton( |     if (build_info.tracked_parts) { | ||||||
|             'fa-sign-in-alt icon-blue', 'button-output-auto', outputId, |  | ||||||
|             '{% trans "Allocate stock items to this build output" %}', |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (lines > 0) { |         // Add a button to allocate stock against this build output | ||||||
|         // Add a button to "cancel" the particular build output (unallocate) |  | ||||||
|         html += makeIconButton( |         html += makeIconButton( | ||||||
|             'fa-minus-circle icon-red', 'button-output-unallocate', outputId, |             'fa-sign-in-alt icon-blue', | ||||||
|  |             'button-output-allocate', | ||||||
|  |             output_id, | ||||||
|  |             '{% trans "Allocate stock items to this build output" %}', | ||||||
|  |             { | ||||||
|  |                 disabled: true, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         // Add a button to unallocate stock from this build output | ||||||
|  |         html += makeIconButton( | ||||||
|  |             'fa-minus-circle icon-red', | ||||||
|  |             'button-output-unallocate', | ||||||
|  |             output_id, | ||||||
|             '{% trans "Unallocate stock from build output" %}', |             '{% trans "Unallocate stock from build output" %}', | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (output) { |     // Add a button to "complete" this build output | ||||||
|  |     html += makeIconButton( | ||||||
|  |         'fa-check-circle icon-green', | ||||||
|  |         'button-output-complete', | ||||||
|  |         output_id, | ||||||
|  |         '{% trans "Complete build output" %}', | ||||||
|  |     ); | ||||||
|  |  | ||||||
|         // Add a button to "complete" the particular build output |     // Add a button to "delete" this build output | ||||||
|         html += makeIconButton( |     html += makeIconButton( | ||||||
|             'fa-check icon-green', 'button-output-complete', outputId, |         'fa-trash-alt icon-red', | ||||||
|             '{% trans "Complete build output" %}', |         'button-output-delete', | ||||||
|             { |         output_id, | ||||||
|                 // disabled: true |         '{% trans "Delete build output" %}', | ||||||
|             } |     ); | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         // Add a button to "delete" the particular build output |     html += `</div>`; | ||||||
|         html += makeIconButton( |  | ||||||
|             'fa-trash-alt icon-red', 'button-output-delete', outputId, |  | ||||||
|             '{% trans "Delete build output" %}', |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         // TODO - Add a button to "destroy" the particular build output (mark as damaged, scrap) |     return html; | ||||||
|     } |  | ||||||
|  |  | ||||||
|     html += '</div>'; |  | ||||||
|  |  | ||||||
|     buildActions.html(html); |  | ||||||
|  |  | ||||||
|     // Add callbacks for the buttons |  | ||||||
|     $(panel).find(`#button-output-auto-${outputId}`).click(function() { |  | ||||||
|  |  | ||||||
|         var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData'); |  | ||||||
|  |  | ||||||
|         // Launch modal dialog to perform auto-allocation |  | ||||||
|         allocateStockToBuild( |  | ||||||
|             buildId, |  | ||||||
|             partId, |  | ||||||
|             bom_items, |  | ||||||
|             { |  | ||||||
|                 source_location: buildInfo.source_location, |  | ||||||
|                 output: outputId, |  | ||||||
|                 success: reloadTable, |  | ||||||
|             } |  | ||||||
|         ); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     $(panel).find(`#button-output-complete-${outputId}`).click(function() { |  | ||||||
|  |  | ||||||
|         var pk = $(this).attr('pk'); |  | ||||||
|  |  | ||||||
|         launchModalForm( |  | ||||||
|             `/build/${buildId}/complete-output/`, |  | ||||||
|             { |  | ||||||
|                 data: { |  | ||||||
|                     output: pk, |  | ||||||
|                 }, |  | ||||||
|                 reload: true, |  | ||||||
|             } |  | ||||||
|         );   |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     $(panel).find(`#button-output-unallocate-${outputId}`).click(function() { |  | ||||||
|  |  | ||||||
|         var pk = $(this).attr('pk'); |  | ||||||
|  |  | ||||||
|         unallocateStock(buildId, { |  | ||||||
|             output: pk, |  | ||||||
|             table: table, |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     $(panel).find(`#button-output-delete-${outputId}`).click(function() { |  | ||||||
|  |  | ||||||
|         var pk = $(this).attr('pk'); |  | ||||||
|  |  | ||||||
|         launchModalForm( |  | ||||||
|             `/build/${buildId}/delete-output/`, |  | ||||||
|             { |  | ||||||
|                 reload: true, |  | ||||||
|                 data: { |  | ||||||
|                     output: pk |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         ); |  | ||||||
|     }); |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -270,14 +200,160 @@ function unallocateStock(build_id, options={}) { | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Launch a modal form to complete selected build outputs | ||||||
|  |  */ | ||||||
|  | function completeBuildOutputs(build_id, outputs, options={}) { | ||||||
|  |      | ||||||
|  |     if (outputs.length == 0) { | ||||||
|  |         showAlertDialog( | ||||||
|  |             '{% trans "Select Build Outputs" %}', | ||||||
|  |             '{% trans "At least one build output must be selected" %}', | ||||||
|  |         ); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Render a single build output (StockItem) | ||||||
|  |     function renderBuildOutput(output, opts={}) { | ||||||
|  |         var pk = output.pk; | ||||||
|  |  | ||||||
|  |         var output_html = imageHoverIcon(output.part_detail.thumbnail); | ||||||
|  |  | ||||||
|  |         if (output.quantity == 1 && output.serial) { | ||||||
|  |             output_html += `{% trans "Serial Number" %}: ${output.serial}`; | ||||||
|  |         } else { | ||||||
|  |             output_html += `{% trans "Quantity" %}: ${output.quantity}`; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var buttons = `<div class='btn-group float-right' role='group'>`; | ||||||
|  |  | ||||||
|  |         buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}'); | ||||||
|  |  | ||||||
|  |         buttons += '</div>'; | ||||||
|  |  | ||||||
|  |         var field = constructField( | ||||||
|  |             `outputs_output_${pk}`, | ||||||
|  |             { | ||||||
|  |                 type: 'raw', | ||||||
|  |                 html: output_html, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 hideLabels: true, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         var html = ` | ||||||
|  |         <tr id='output_row_${pk}'> | ||||||
|  |             <td>${field}</td> | ||||||
|  |             <td>${output.part_detail.full_name}</td> | ||||||
|  |             <td>${buttons}</td> | ||||||
|  |         </tr>`; | ||||||
|  |  | ||||||
|  |         return html; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Construct table entries | ||||||
|  |     var table_entries = ''; | ||||||
|  |  | ||||||
|  |     outputs.forEach(function(output) { | ||||||
|  |         table_entries += renderBuildOutput(output); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     var html = ` | ||||||
|  |     <table class='table table-striped table-condensed' id='build-complete-table'> | ||||||
|  |         <thead> | ||||||
|  |             <th colspan='2'>{% trans "Output" %}</th> | ||||||
|  |             <th><!-- Actions --></th> | ||||||
|  |         </thead> | ||||||
|  |         <tbody> | ||||||
|  |             ${table_entries} | ||||||
|  |         </tbody> | ||||||
|  |     </table>`; | ||||||
|  |  | ||||||
|  |     constructForm(`/api/build/${build_id}/complete/`, { | ||||||
|  |         method: 'POST', | ||||||
|  |         preFormContent: html, | ||||||
|  |         fields: { | ||||||
|  |             status: {}, | ||||||
|  |             location: {}, | ||||||
|  |         }, | ||||||
|  |         confirm: true, | ||||||
|  |         title: '{% trans "Complete Build Outputs" %}', | ||||||
|  |         afterRender: function(fields, opts) { | ||||||
|  |             // Setup callbacks to remove outputs | ||||||
|  |             $(opts.modal).find('.button-row-remove').click(function() { | ||||||
|  |                 var pk = $(this).attr('pk'); | ||||||
|  |  | ||||||
|  |                 $(opts.modal).find(`#output_row_${pk}`).remove(); | ||||||
|  |             }); | ||||||
|  |         }, | ||||||
|  |         onSubmit: function(fields, opts) { | ||||||
|  |              | ||||||
|  |             // Extract data elements from the form | ||||||
|  |             var data = { | ||||||
|  |                 outputs: [], | ||||||
|  |                 status: getFormFieldValue('status', {}, opts), | ||||||
|  |                 location: getFormFieldValue('location', {}, opts), | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             var output_pk_values = []; | ||||||
|  |  | ||||||
|  |             outputs.forEach(function(output) { | ||||||
|  |                 var pk = output.pk; | ||||||
|  |  | ||||||
|  |                 var row = $(opts.modal).find(`#output_row_${pk}`); | ||||||
|  |  | ||||||
|  |                 if (row.exists()) { | ||||||
|  |                     data.outputs.push({ | ||||||
|  |                         output: pk, | ||||||
|  |                     }); | ||||||
|  |                     output_pk_values.push(pk); | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             // Provide list of nested values | ||||||
|  |             opts.nested = { | ||||||
|  |                 'outputs': output_pk_values, | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             inventreePut( | ||||||
|  |                 opts.url, | ||||||
|  |                 data, | ||||||
|  |                 { | ||||||
|  |                     method: 'POST', | ||||||
|  |                     success: function(response) { | ||||||
|  |                         // Hide the modal | ||||||
|  |                         $(opts.modal).modal('hide'); | ||||||
|  |  | ||||||
|  |                         if (options.success) { | ||||||
|  |                             options.success(response); | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     error: function(xhr) { | ||||||
|  |                         switch (xhr.status) { | ||||||
|  |                         case 400: | ||||||
|  |                             handleFormErrors(xhr.responseJSON, fields, opts); | ||||||
|  |                             break; | ||||||
|  |                         default: | ||||||
|  |                             $(opts.modal).modal('hide'); | ||||||
|  |                             showApiError(xhr); | ||||||
|  |                             break; | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Load a table showing all the BuildOrder allocations for a given part | ||||||
|  |  */ | ||||||
| function loadBuildOrderAllocationTable(table, options={}) { | function loadBuildOrderAllocationTable(table, options={}) { | ||||||
|     /** |  | ||||||
|      * Load a table showing all the BuildOrder allocations for a given part |  | ||||||
|      */ |  | ||||||
|  |  | ||||||
|     options.params['part_detail'] = true; |     options.params['part_detail'] = true; | ||||||
|     options.params['build_detail'] = true; |     options.params['build_detail'] = true; | ||||||
| @@ -357,17 +433,256 @@ function loadBuildOrderAllocationTable(table, options={}) { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| function loadBuildOutputAllocationTable(buildInfo, output, options={}) { | /* | ||||||
|  |  * Display a "build output" table for a particular build. | ||||||
|  |  * | ||||||
|  |  * This displays a list of "active" (i.e. "in production") build outputs for a given build | ||||||
|  |  *  | ||||||
|  |  */ | ||||||
|  | function loadBuildOutputTable(build_info, options={}) { | ||||||
|  |  | ||||||
|  |     var table = options.table || '#build-output-table'; | ||||||
|  |  | ||||||
|  |     var params = options.params || {}; | ||||||
|  |  | ||||||
|  |     // Mandatory query filters | ||||||
|  |     params.part_detail = true; | ||||||
|  |     params.is_building = true; | ||||||
|  |     params.build = build_info.pk; | ||||||
|  |  | ||||||
|  |     // Construct a list of "tracked" BOM items | ||||||
|  |     var tracked_bom_items = []; | ||||||
|  |  | ||||||
|  |     var has_tracked_items = false; | ||||||
|  |  | ||||||
|  |     build_info.bom_items.forEach(function(bom_item) { | ||||||
|  |         if (bom_item.sub_part_detail.trackable) { | ||||||
|  |             tracked_bom_items.push(bom_item); | ||||||
|  |             has_tracked_items = true; | ||||||
|  |         }; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     var filters = {}; | ||||||
|  |  | ||||||
|  |     for (var key in params) { | ||||||
|  |         filters[key] = params[key]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // TODO: Initialize filter list | ||||||
|  |  | ||||||
|  |     function setupBuildOutputButtonCallbacks() { | ||||||
|  |          | ||||||
|  |         // Callback for the "allocate" button | ||||||
|  |         $(table).find('.button-output-allocate').click(function() { | ||||||
|  |             var pk = $(this).attr('pk'); | ||||||
|  |  | ||||||
|  |             // Find the "allocation" sub-table associated with this output | ||||||
|  |             var subtable = $(`#output-sub-table-${pk}`); | ||||||
|  |  | ||||||
|  |             if (subtable.exists()) { | ||||||
|  |                 var rows = subtable.bootstrapTable('getSelections'); | ||||||
|  |  | ||||||
|  |                 // None selected? Use all! | ||||||
|  |                 if (rows.length == 0) { | ||||||
|  |                     rows = subtable.bootstrapTable('getData'); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 allocateStockToBuild( | ||||||
|  |                     build_info.pk, | ||||||
|  |                     build_info.part, | ||||||
|  |                     rows, | ||||||
|  |                     { | ||||||
|  |                         output: pk, | ||||||
|  |                         success: function() { | ||||||
|  |                             $(table).bootstrapTable('refresh'); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 ); | ||||||
|  |             } else { | ||||||
|  |                 console.log(`WARNING: Could not locate sub-table for output ${pk}`); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Callack for the "unallocate" button | ||||||
|  |         $(table).find('.button-output-unallocate').click(function() { | ||||||
|  |             var pk = $(this).attr('pk'); | ||||||
|  |  | ||||||
|  |             unallocateStock(build_info.pk, { | ||||||
|  |                 output: pk, | ||||||
|  |                 table: table | ||||||
|  |             }); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Callback for the "complete" button | ||||||
|  |         $(table).find('.button-output-complete').click(function() { | ||||||
|  |             var pk = $(this).attr('pk'); | ||||||
|  |  | ||||||
|  |             var output = $(table).bootstrapTable('getRowByUniqueId', pk); | ||||||
|  |  | ||||||
|  |             completeBuildOutputs( | ||||||
|  |                 build_info.pk, | ||||||
|  |                 [ | ||||||
|  |                     output, | ||||||
|  |                 ], | ||||||
|  |                 { | ||||||
|  |                     success: function() { | ||||||
|  |                         $(table).bootstrapTable('refresh'); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Callback for the "delete" button | ||||||
|  |         $(table).find('.button-output-delete').click(function() { | ||||||
|  |             var pk = $(this).attr('pk'); | ||||||
|  |  | ||||||
|  |             // TODO: Move this to the API | ||||||
|  |             launchModalForm( | ||||||
|  |                 `/build/${build_info.pk}/delete-output/`, | ||||||
|  |                 { | ||||||
|  |                     data: { | ||||||
|  |                         output: pk | ||||||
|  |                     }, | ||||||
|  |                     onSuccess: function() { | ||||||
|  |                         $(table).bootstrapTable('refresh'); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /* |     /* | ||||||
|      * Load the "allocation table" for a particular build output. |      * Construct a "sub table" showing the required BOM items | ||||||
|      *  |  | ||||||
|      * Args: |  | ||||||
|      * - buildId: The PK of the Build object |  | ||||||
|      * - partId: The PK of the Part object |  | ||||||
|      * - output: The StockItem object which is the "output" of the build |  | ||||||
|      * - options: |  | ||||||
|      * -- table: The #id of the table (will be auto-calculated if not provided) |  | ||||||
|      */ |      */ | ||||||
|  |     function constructBuildOutputSubTable(index, row, element) { | ||||||
|  |         var sub_table_id = `output-sub-table-${row.pk}`; | ||||||
|  |  | ||||||
|  |         var html = ` | ||||||
|  |         <div class='sub-table'> | ||||||
|  |             <table class='table table-striped table-condensed' id='${sub_table_id}'></table> | ||||||
|  |         </div> | ||||||
|  |         `; | ||||||
|  |  | ||||||
|  |         element.html(html); | ||||||
|  |  | ||||||
|  |         loadBuildOutputAllocationTable( | ||||||
|  |             build_info, | ||||||
|  |             row, | ||||||
|  |             { | ||||||
|  |                 table: `#${sub_table_id}`, | ||||||
|  |                 parent_table: table, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     $(table).inventreeTable({ | ||||||
|  |         url: '{% url "api-stock-list" %}', | ||||||
|  |         queryParams: filters, | ||||||
|  |         original: params, | ||||||
|  |         showColumns: false, | ||||||
|  |         uniqueId: 'pk', | ||||||
|  |         name: 'build-outputs', | ||||||
|  |         sortable: true, | ||||||
|  |         search: false, | ||||||
|  |         sidePagination: 'server', | ||||||
|  |         detailView: has_tracked_items, | ||||||
|  |         detailFilter: function(index, row) { | ||||||
|  |             return true; | ||||||
|  |         }, | ||||||
|  |         detailFormatter: function(index, row, element) { | ||||||
|  |             constructBuildOutputSubTable(index, row, element); | ||||||
|  |         }, | ||||||
|  |         formatNoMatches: function() { | ||||||
|  |             return '{% trans "No active build outputs found" %}'; | ||||||
|  |         }, | ||||||
|  |         onPostBody: function() { | ||||||
|  |             // Add callbacks for the buttons | ||||||
|  |             setupBuildOutputButtonCallbacks(); | ||||||
|  |  | ||||||
|  |             $(table).bootstrapTable('expandAllRows'); | ||||||
|  |         }, | ||||||
|  |         columns: [ | ||||||
|  |             { | ||||||
|  |                 title: '', | ||||||
|  |                 visible: true, | ||||||
|  |                 checkbox: true, | ||||||
|  |                 switchable: false, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 field: 'part', | ||||||
|  |                 title: '{% trans "Part" %}', | ||||||
|  |                 formatter: function(value, row) { | ||||||
|  |                     var thumb = row.part_detail.thumbnail; | ||||||
|  |  | ||||||
|  |                     return imageHoverIcon(thumb) + row.part_detail.full_name + makePartIcons(row.part_detail); | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 field: 'quantity', | ||||||
|  |                 title: '{% trans "Quantity" %}', | ||||||
|  |                 formatter: function(value, row) { | ||||||
|  |  | ||||||
|  |                     var url = `/stock/item/${row.pk}/`; | ||||||
|  |  | ||||||
|  |                     var text = ''; | ||||||
|  |  | ||||||
|  |                     if (row.serial && row.quantity == 1) { | ||||||
|  |                         text = `{% trans "Serial Number" %}: ${row.serial}`; | ||||||
|  |                     } else { | ||||||
|  |                         text = `{% trans "Quantity" %}: ${row.quantity}`; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     return renderLink(text, url); | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 field: 'allocated', | ||||||
|  |                 title: '{% trans "Allocated Parts" %}', | ||||||
|  |                 visible: has_tracked_items, | ||||||
|  |                 formatter: function(value, row) { | ||||||
|  |                     return `<div id='output-progress-${row.pk}'><span class='fas fa-spin fa-spinner'></span></div>`; | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 field: 'actions', | ||||||
|  |                 title: '', | ||||||
|  |                 switchable: false, | ||||||
|  |                 formatter: function(value, row) { | ||||||
|  |                     return makeBuildOutputButtons( | ||||||
|  |                         row.pk, | ||||||
|  |                         build_info, | ||||||
|  |                     ); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         ] | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // Enable the "allocate" button when the sub-table is exanded | ||||||
|  |     $(table).on('expand-row.bs.table', function(detail, index, row) { | ||||||
|  |         $(`#button-output-allocate-${row.pk}`).prop('disabled', false); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     // Disable the "allocate" button when the sub-table is collapsed | ||||||
|  |     $(table).on('collapse-row.bs.table', function(detail, index, row) { | ||||||
|  |         $(`#button-output-allocate-${row.pk}`).prop('disabled', true); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* | ||||||
|  |  * Display the "allocation table" for a particular build output. | ||||||
|  |  *  | ||||||
|  |  * This displays a table of required allocations for a particular build output | ||||||
|  |  *  | ||||||
|  |  * Args: | ||||||
|  |  * - buildId: The PK of the Build object | ||||||
|  |  * - partId: The PK of the Part object | ||||||
|  |  * - output: The StockItem object which is the "output" of the build | ||||||
|  |  * - options: | ||||||
|  |  * -- table: The #id of the table (will be auto-calculated if not provided) | ||||||
|  |  */ | ||||||
|  | function loadBuildOutputAllocationTable(buildInfo, output, options={}) { | ||||||
|  |      | ||||||
|  |  | ||||||
|     var buildId = buildInfo.pk; |     var buildId = buildInfo.pk; | ||||||
|     var partId = buildInfo.part; |     var partId = buildInfo.part; | ||||||
| @@ -534,7 +849,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { | |||||||
|         }, |         }, | ||||||
|         name: 'build-allocation', |         name: 'build-allocation', | ||||||
|         uniqueId: 'sub_part', |         uniqueId: 'sub_part', | ||||||
|         onPostBody: setupCallbacks, |         search: options.search || false, | ||||||
|  |         onPostBody: function(data) { | ||||||
|  |             // Setup button callbacks | ||||||
|  |             setupCallbacks(); | ||||||
|  |         }, | ||||||
|         onLoadSuccess: function(tableData) { |         onLoadSuccess: function(tableData) { | ||||||
|             // Once the BOM data are loaded, request allocation data for this build output |             // Once the BOM data are loaded, request allocation data for this build output | ||||||
|  |  | ||||||
| @@ -610,31 +929,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { | |||||||
|                             $(table).bootstrapTable('updateByUniqueId', key, tableRow, true); |                             $(table).bootstrapTable('updateByUniqueId', key, tableRow, true); | ||||||
|                         } |                         } | ||||||
|  |  | ||||||
|                         // Update the total progress for this build output |                         // Update the progress bar for this build output | ||||||
|                         var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`)); |                         var build_progress = $(`#output-progress-${outputId}`); | ||||||
|  |  | ||||||
|                         if (totalLines > 0) { |                         if (build_progress.exists()) { | ||||||
|  |                             if (totalLines > 0) { | ||||||
|  |  | ||||||
|                             var progress = makeProgressBar( |                                 var progress = makeProgressBar( | ||||||
|                                 allocatedLines, |                                     allocatedLines, | ||||||
|                                 totalLines |                                     totalLines | ||||||
|                             ); |                                 ); | ||||||
|  |      | ||||||
|                             buildProgress.html(progress); |                                 build_progress.html(progress); | ||||||
|  |                             } else { | ||||||
|  |                                 build_progress.html(''); | ||||||
|  |                             } | ||||||
|  |      | ||||||
|                         } else { |                         } else { | ||||||
|                             buildProgress.html(''); |                             console.log(`WARNING: Could not find progress bar for output ${outputId}`); | ||||||
|                         } |                         } | ||||||
|  |  | ||||||
|                         // Update the available actions for this build output |  | ||||||
|  |  | ||||||
|                         makeBuildOutputActionButtons(output, buildInfo, totalLines); |  | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             ); |             ); | ||||||
|         }, |         }, | ||||||
|         sortable: true, |         sortable: true, | ||||||
|         showColumns: false, |         showColumns: false, | ||||||
|         detailViewByClick: true, |  | ||||||
|         detailView: true, |         detailView: true, | ||||||
|         detailFilter: function(index, row) { |         detailFilter: function(index, row) { | ||||||
|             return row.allocations != null; |             return row.allocations != null; | ||||||
| @@ -883,9 +1202,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { | |||||||
|             }, |             }, | ||||||
|         ] |         ] | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     // Initialize the action buttons |  | ||||||
|     makeBuildOutputActionButtons(output, buildInfo, 0); |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -995,10 +1311,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { | |||||||
|             remaining = 0; |             remaining = 0; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         table_entries += renderBomItemRow(bom_item, remaining); |         // We only care about entries which are not yet fully allocated | ||||||
|  |         if (remaining > 0) { | ||||||
|  |             table_entries += renderBomItemRow(bom_item, remaining); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (bom_items.length == 0) { |     if (table_entries.length == 0) { | ||||||
|  |  | ||||||
|         showAlertDialog( |         showAlertDialog( | ||||||
|             '{% trans "Select Parts" %}', |             '{% trans "Select Parts" %}', | ||||||
| @@ -1085,6 +1404,24 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { | |||||||
|                         render_part_detail: true, |                         render_part_detail: true, | ||||||
|                         render_location_detail: true, |                         render_location_detail: true, | ||||||
|                         auto_fill: true, |                         auto_fill: true, | ||||||
|  |                         onSelect: function(data, field, opts) { | ||||||
|  |                             // Adjust the 'quantity' field based on availability | ||||||
|  |  | ||||||
|  |                             if (!('quantity' in data)) { | ||||||
|  |                                 return; | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             // Quantity remaining to be allocated | ||||||
|  |                             var remaining = Math.max((bom_item.required || 0) - (bom_item.allocated || 0), 0); | ||||||
|  |  | ||||||
|  |                             // Calculate the available quantity | ||||||
|  |                             var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); | ||||||
|  |  | ||||||
|  |                             // Maximum amount that we need | ||||||
|  |                             var desired = Math.min(available, remaining); | ||||||
|  |  | ||||||
|  |                             updateFieldValue(`items_quantity_${bom_item.pk}`, desired, {}, opts); | ||||||
|  |                         }, | ||||||
|                         adjustFilters: function(filters) { |                         adjustFilters: function(filters) { | ||||||
|                             // Restrict query to the selected location |                             // Restrict query to the selected location | ||||||
|                             var location = getFormFieldValue( |                             var location = getFormFieldValue( | ||||||
| @@ -1198,9 +1535,10 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* | ||||||
|  |  * Display a table of Build orders | ||||||
|  |  */ | ||||||
| function loadBuildTable(table, options) { | function loadBuildTable(table, options) { | ||||||
|     // Display a table of Build objects |  | ||||||
|  |  | ||||||
|     var params = options.params || {}; |     var params = options.params || {}; | ||||||
|  |  | ||||||
| @@ -1467,190 +1805,4 @@ function loadAllocationTable(table, part_id, part, url, required, button) { | |||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| function loadBuildPartsTable(table, options={}) { |  | ||||||
|     /** |  | ||||||
|      * Display a "required parts" table for build view. |  | ||||||
|      *  |  | ||||||
|      * This is a simplified BOM view: |  | ||||||
|      * - Does not display sub-bom items |  | ||||||
|      * - Does not allow editing of BOM items |  | ||||||
|      *  |  | ||||||
|      * Options: |  | ||||||
|      *  |  | ||||||
|      * part: Part ID |  | ||||||
|      * build: Build ID |  | ||||||
|      * build_quantity: Total build quantity |  | ||||||
|      * build_remaining: Number of items remaining |  | ||||||
|      */ |  | ||||||
|  |  | ||||||
|     // Query params |  | ||||||
|     var params = { |  | ||||||
|         sub_part_detail: true, |  | ||||||
|         part: options.part, |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     var filters = {}; |  | ||||||
|  |  | ||||||
|     if (!options.disableFilters) { |  | ||||||
|         filters = loadTableFilters('bom'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     setupFilterList('bom', $(table)); |  | ||||||
|  |  | ||||||
|     for (var key in params) { |  | ||||||
|         filters[key] = params[key]; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     function setupTableCallbacks() { |  | ||||||
|         // Register button callbacks once the table data are loaded |  | ||||||
|  |  | ||||||
|         // Callback for 'buy' button |  | ||||||
|         $(table).find('.button-buy').click(function() { |  | ||||||
|             var pk = $(this).attr('pk'); |  | ||||||
|  |  | ||||||
|             launchModalForm('{% url "order-parts" %}', { |  | ||||||
|                 data: { |  | ||||||
|                     parts: [ |  | ||||||
|                         pk, |  | ||||||
|                     ] |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         // Callback for 'build' button |  | ||||||
|         $(table).find('.button-build').click(function() { |  | ||||||
|             var pk = $(this).attr('pk'); |  | ||||||
|  |  | ||||||
|             newBuildOrder({ |  | ||||||
|                 part: pk, |  | ||||||
|                 parent: options.build, |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     var columns = [ |  | ||||||
|         { |  | ||||||
|             field: 'sub_part', |  | ||||||
|             title: '{% trans "Part" %}', |  | ||||||
|             switchable: false, |  | ||||||
|             sortable: true, |  | ||||||
|             formatter: function(value, row) { |  | ||||||
|                 var url = `/part/${row.sub_part}/`; |  | ||||||
|                 var html = imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, url); |  | ||||||
|  |  | ||||||
|                 var sub_part = row.sub_part_detail; |  | ||||||
|  |  | ||||||
|                 html += makePartIcons(row.sub_part_detail); |  | ||||||
|  |  | ||||||
|                 // Display an extra icon if this part is an assembly |  | ||||||
|                 if (sub_part.assembly) { |  | ||||||
|                     var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`; |  | ||||||
|  |  | ||||||
|                     html += renderLink(text, `/part/${row.sub_part}/bom/`); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 return html; |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|             field: 'sub_part_detail.description', |  | ||||||
|             title: '{% trans "Description" %}', |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|             field: 'reference', |  | ||||||
|             title: '{% trans "Reference" %}', |  | ||||||
|             searchable: true, |  | ||||||
|             sortable: true, |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|             field: 'quantity', |  | ||||||
|             title: '{% trans "Quantity" %}', |  | ||||||
|             sortable: true |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|             sortable: true, |  | ||||||
|             switchable: false, |  | ||||||
|             field: 'sub_part_detail.stock', |  | ||||||
|             title: '{% trans "Available" %}', |  | ||||||
|             formatter: function(value, row) { |  | ||||||
|                 return makeProgressBar( |  | ||||||
|                     value, |  | ||||||
|                     row.quantity * options.build_remaining, |  | ||||||
|                     { |  | ||||||
|                         id: `part-progress-${row.part}` |  | ||||||
|                     } |  | ||||||
|                 ); |  | ||||||
|             }, |  | ||||||
|             sorter: function(valA, valB, rowA, rowB) { |  | ||||||
|                 if (rowA.received == 0 && rowB.received == 0) { |  | ||||||
|                     return (rowA.quantity > rowB.quantity) ? 1 : -1; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 var progressA = parseFloat(rowA.sub_part_detail.stock) / (rowA.quantity * options.build_remaining); |  | ||||||
|                 var progressB = parseFloat(rowB.sub_part_detail.stock) / (rowB.quantity * options.build_remaining); |  | ||||||
|  |  | ||||||
|                 return (progressA < progressB) ? 1 : -1; |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|             field: 'actions', |  | ||||||
|             title: '{% trans "Actions" %}', |  | ||||||
|             switchable: false, |  | ||||||
|             formatter: function(value, row) { |  | ||||||
|  |  | ||||||
|                 // Generate action buttons against the part |  | ||||||
|                 var html = `<div class='btn-group float-right' role='group'>`; |  | ||||||
|  |  | ||||||
|                 if (row.sub_part_detail.assembly) { |  | ||||||
|                     html += makeIconButton('fa-tools icon-blue', 'button-build', row.sub_part, '{% trans "Build stock" %}'); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 if (row.sub_part_detail.purchaseable) { |  | ||||||
|                     html += makeIconButton('fa-shopping-cart icon-blue', 'button-buy', row.sub_part, '{% trans "Order stock" %}'); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 html += `</div>`; |  | ||||||
|  |  | ||||||
|                 return html; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     ]; |  | ||||||
|  |  | ||||||
|     table.inventreeTable({ |  | ||||||
|         url: '{% url "api-bom-list" %}', |  | ||||||
|         showColumns: true, |  | ||||||
|         name: 'build-parts', |  | ||||||
|         sortable: true, |  | ||||||
|         search: true, |  | ||||||
|         onPostBody: setupTableCallbacks, |  | ||||||
|         rowStyle: function(row) { |  | ||||||
|             var classes = []; |  | ||||||
|  |  | ||||||
|             // Shade rows differently if they are for different parent parts |  | ||||||
|             if (row.part != options.part) { |  | ||||||
|                 classes.push('rowinherited'); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             if (row.validated) { |  | ||||||
|                 classes.push('rowvalid'); |  | ||||||
|             } else { |  | ||||||
|                 classes.push('rowinvalid'); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             return { |  | ||||||
|                 classes: classes.join(' '), |  | ||||||
|             }; |  | ||||||
|         }, |  | ||||||
|         formatNoMatches: function() { |  | ||||||
|             return '{% trans "No BOM items found" %}'; |  | ||||||
|         }, |  | ||||||
|         clickToSelect: true, |  | ||||||
|         queryParams: filters, |  | ||||||
|         original: params, |  | ||||||
|         columns: columns, |  | ||||||
|     });     |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1426,6 +1426,11 @@ function initializeRelatedField(field, fields, options) { | |||||||
|                 data = item.element.instance; |                 data = item.element.instance; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             // Run optional callback function | ||||||
|  |             if (field.onSelect && data) { | ||||||
|  |                 field.onSelect(data, field, options); | ||||||
|  |             } | ||||||
|  |  | ||||||
|             if (!data.pk) { |             if (!data.pk) { | ||||||
|                 return field.placeholder || ''; |                 return field.placeholder || ''; | ||||||
|             } |             } | ||||||
| @@ -1843,6 +1848,8 @@ function constructInput(name, parameters, options) { | |||||||
|     case 'candy': |     case 'candy': | ||||||
|         func = constructCandyInput; |         func = constructCandyInput; | ||||||
|         break; |         break; | ||||||
|  |     case 'raw': | ||||||
|  |         func = constructRawInput; | ||||||
|     default: |     default: | ||||||
|         // Unsupported field type! |         // Unsupported field type! | ||||||
|         break; |         break; | ||||||
| @@ -2086,6 +2093,17 @@ function constructCandyInput(name, parameters) { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* | ||||||
|  |  * Construct a "raw" field input | ||||||
|  |  * No actual field data! | ||||||
|  |  */ | ||||||
|  | function constructRawInput(name, parameters) { | ||||||
|  |  | ||||||
|  |     return parameters.html; | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
| /* | /* | ||||||
|  * Construct a 'help text' div based on the field parameters |  * Construct a 'help text' div based on the field parameters | ||||||
|  *  |  *  | ||||||
|   | |||||||
| @@ -87,8 +87,10 @@ function select2Thumbnail(image) { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* | ||||||
|  |  * Construct an 'icon badge' which floats to the right of an object | ||||||
|  |  */ | ||||||
| function makeIconBadge(icon, title) { | function makeIconBadge(icon, title) { | ||||||
|     // Construct an 'icon badge' which floats to the right of an object |  | ||||||
|  |  | ||||||
|     var html = `<span class='fas ${icon} label-right' title='${title}'></span>`; |     var html = `<span class='fas ${icon} label-right' title='${title}'></span>`; | ||||||
|  |  | ||||||
| @@ -96,8 +98,10 @@ function makeIconBadge(icon, title) { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* | ||||||
|  |  * Construct an 'icon button' using the fontawesome set | ||||||
|  |  */ | ||||||
| function makeIconButton(icon, cls, pk, title, options={}) { | function makeIconButton(icon, cls, pk, title, options={}) { | ||||||
|     // Construct an 'icon button' using the fontawesome set |  | ||||||
|  |  | ||||||
|     var classes = `btn btn-default btn-glyph ${cls}`; |     var classes = `btn btn-default btn-glyph ${cls}`; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -168,11 +168,7 @@ function renderPart(name, data, parameters, options) { | |||||||
|  |  | ||||||
|     // Display available part quantity |     // Display available part quantity | ||||||
|     if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) { |     if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) { | ||||||
|         if (data.in_stock == 0) { |         extra += partStockLabel(data); | ||||||
|             extra += `<span class='label-form label-red'>{% trans "No Stock" %}</span>`; |  | ||||||
|         } else { |  | ||||||
|             extra += `<span class='label-form label-green'>{% trans "Stock" %}: ${data.in_stock}</span>`; |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (!data.active) { |     if (!data.active) { | ||||||
|   | |||||||
| @@ -1641,6 +1641,13 @@ function loadSalesOrderLineItemTable(table, options={}) { | |||||||
|  |  | ||||||
|             var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); |             var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); | ||||||
|  |  | ||||||
|  |             // Quantity remaining to be allocated | ||||||
|  |             var remaining = (line_item.quantity || 0) - (line_item.allocated || 0); | ||||||
|  |  | ||||||
|  |             if (remaining < 0) { | ||||||
|  |                 remaining = 0; | ||||||
|  |             } | ||||||
|  |  | ||||||
|             var fields = { |             var fields = { | ||||||
|                 // SalesOrderLineItem reference |                 // SalesOrderLineItem reference | ||||||
|                 line: { |                 line: { | ||||||
| @@ -1654,9 +1661,26 @@ function loadSalesOrderLineItemTable(table, options={}) { | |||||||
|                         in_stock: true, |                         in_stock: true, | ||||||
|                         part: line_item.part, |                         part: line_item.part, | ||||||
|                         exclude_so_allocation: options.order, |                         exclude_so_allocation: options.order, | ||||||
|                     }  |                     }, | ||||||
|  |                     auto_fill: true, | ||||||
|  |                     onSelect: function(data, field, opts) { | ||||||
|  |                         // Quantity available from this stock item | ||||||
|  |  | ||||||
|  |                         if (!('quantity' in data)) { | ||||||
|  |                             return; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         // Calculate the available quantity | ||||||
|  |                         var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); | ||||||
|  |  | ||||||
|  |                         // Maximum amount that we need | ||||||
|  |                         var desired = Math.min(available, remaining); | ||||||
|  |  | ||||||
|  |                         updateFieldValue('quantity', desired, {}, opts); | ||||||
|  |                     } | ||||||
|                 }, |                 }, | ||||||
|                 quantity: { |                 quantity: { | ||||||
|  |                     value: remaining, | ||||||
|                 }, |                 }, | ||||||
|             }; |             }; | ||||||
|  |  | ||||||
| @@ -1752,7 +1776,7 @@ function loadSalesOrderLineItemTable(table, options={}) { | |||||||
|         showFooter: true, |         showFooter: true, | ||||||
|         uniqueId: 'pk', |         uniqueId: 'pk', | ||||||
|         detailView: show_detail, |         detailView: show_detail, | ||||||
|         detailViewByClick: show_detail, |         detailViewByClick: false, | ||||||
|         detailFilter: function(index, row) { |         detailFilter: function(index, row) { | ||||||
|             if (pending) { |             if (pending) { | ||||||
|                 // Order is pending |                 // Order is pending | ||||||
|   | |||||||
| @@ -35,6 +35,7 @@ | |||||||
|     loadSellPricingChart, |     loadSellPricingChart, | ||||||
|     loadSimplePartTable, |     loadSimplePartTable, | ||||||
|     loadStockPricingChart, |     loadStockPricingChart, | ||||||
|  |     partStockLabel, | ||||||
|     toggleStar, |     toggleStar, | ||||||
| */ | */ | ||||||
|  |  | ||||||
| @@ -409,6 +410,18 @@ function toggleStar(options) { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | function partStockLabel(part, options={}) { | ||||||
|  |  | ||||||
|  |     var label_class = options.label_class || 'label-form'; | ||||||
|  |  | ||||||
|  |     if (part.in_stock) { | ||||||
|  |         return `<span class='label ${label_class} label-green'>{% trans "Stock" %}: ${part.in_stock}</span>`; | ||||||
|  |     } else { | ||||||
|  |         return `<span class='label ${label_class} label-red'>{% trans "No Stock" %}</span>`; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
| function makePartIcons(part) { | function makePartIcons(part) { | ||||||
|     /* Render a set of icons for the given part. |     /* Render a set of icons for the given part. | ||||||
|      */ |      */ | ||||||
| @@ -778,7 +791,7 @@ function partGridTile(part) { | |||||||
|  |  | ||||||
|     var html = ` |     var html = ` | ||||||
|      |      | ||||||
|     <div class='col-sm-3 card'> |     <div class='product-card card'> | ||||||
|         <div class='panel panel-default panel-inventree product-card-panel'> |         <div class='panel panel-default panel-inventree product-card-panel'> | ||||||
|             <div class='panel-heading'> |             <div class='panel-heading'> | ||||||
|                 <a href='/part/${part.pk}/'> |                 <a href='/part/${part.pk}/'> | ||||||
| @@ -1000,8 +1013,8 @@ function loadPartTable(table, url, options={}) { | |||||||
|  |  | ||||||
|             data.forEach(function(row, index) { |             data.forEach(function(row, index) { | ||||||
|                  |                  | ||||||
|                 // Force a new row every 4 columns, to prevent visual issues |                 // Force a new row every 5 columns | ||||||
|                 if ((index > 0) && (index % 4 == 0) && (index < data.length)) { |                 if ((index > 0) && (index % 5 == 0) && (index < data.length)) { | ||||||
|                     html += `</div><div class='row full-height'>`; |                     html += `</div><div class='row full-height'>`; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -56,10 +56,10 @@ function enableButtons(elements, enabled) { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* Link a bootstrap-table object to one or more buttons. | ||||||
|  |  * The buttons will only be enabled if there is at least one row selected | ||||||
|  |  */ | ||||||
| function linkButtonsToSelection(table, buttons) { | function linkButtonsToSelection(table, buttons) { | ||||||
|     /* Link a bootstrap-table object to one or more buttons. |  | ||||||
|      * The buttons will only be enabled if there is at least one row selected |  | ||||||
|      */ |  | ||||||
|  |  | ||||||
|     if (typeof table === 'string') { |     if (typeof table === 'string') { | ||||||
|         table = $(table); |         table = $(table); | ||||||
|   | |||||||
| @@ -1,23 +0,0 @@ | |||||||
| <table class='table table-striped table-condensed' id='{{ table_id }}'> |  | ||||||
|     <tr> |  | ||||||
|         <th data-field='part' data-sortable='true' data-searchable='true'>Part</th> |  | ||||||
|         <th data-field='part' data-sortable='true' data-searchable='true'>Description</th> |  | ||||||
|         <th data-field='part' data-sortable='true' data-searchable='true'>In Stock</th> |  | ||||||
|         <th data-field='part' data-sortable='true' data-searchable='true'>On Order</th> |  | ||||||
|         <th data-field='part' data-sortable='true' data-searchable='true'>Allocted</th> |  | ||||||
|         <th data-field='part' data-sortable='true' data-searchable='true'>Net Stock</th> |  | ||||||
|     </tr> |  | ||||||
|     {% for part in parts %} |  | ||||||
|     <tr> |  | ||||||
|         <td> |  | ||||||
|             {% include "hover_image.html" with image=part.image hover=True %} |  | ||||||
|             <a href="{% url 'part-detail' part.id %}">{{ part.full_name }}</a> |  | ||||||
|         </td> |  | ||||||
|         <td>{{ part.description }}</td> |  | ||||||
|         <td>{{ part.total_stock }}</td> |  | ||||||
|         <td>{{ part.on_order }}</td> |  | ||||||
|         <td>{{ part.allocation_count }}</td> |  | ||||||
|         <td{% if part.net_stock < 0 %} class='red-cell'{% endif %}>{{ part.net_stock }}</td> |  | ||||||
|     </tr> |  | ||||||
|     {% endfor %} |  | ||||||
| </table> |  | ||||||
| @@ -1,3 +0,0 @@ | |||||||
| <div> |  | ||||||
|     <input fieldname='{{ field }}' class='slidey' type="checkbox" data-offstyle='warning' data-onstyle="success" data-size='small' data-toggle="toggle" {% if disabled or not roles.part.change %}disabled {% endif %}{% if state %}checked=""{% endif %} autocomplete="off"> |  | ||||||
| </div> |  | ||||||
		Reference in New Issue
	
	Block a user