diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 45565f1d6a..263e28fc01 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -105,9 +105,14 @@ function makeProgressBar(value, maximum, opts) { var options = opts || {}; value = parseFloat(value); - maximum = parseFloat(maximum); - var percent = parseInt(value / maximum * 100); + var percent = 100; + + // Prevent div-by-zero or null value + if (maximum && maximum > 0) { + maximum = parseFloat(maximum); + percent = parseInt(value / maximum * 100); + } if (percent > 100) { percent = 100; @@ -115,18 +120,28 @@ function makeProgressBar(value, maximum, opts) { var extraclass = ''; - if (value > maximum) { + if (maximum) { + // TODO - Special color? + } + else if (value > maximum) { extraclass='progress-bar-over'; } else if (value < maximum) { extraclass = 'progress-bar-under'; } + var text = value; + + if (maximum) { + text += ' / '; + text += maximum; + } + var id = options.id || 'progress-bar'; return `
-
${value} / ${maximum}
+
${text}
`; } diff --git a/InvenTree/InvenTree/static/script/inventree/tables.js b/InvenTree/InvenTree/static/script/inventree/tables.js index cc4320307b..6d57240979 100644 --- a/InvenTree/InvenTree/static/script/inventree/tables.js +++ b/InvenTree/InvenTree/static/script/inventree/tables.js @@ -109,10 +109,20 @@ $.fn.inventreeTable = function(options) { options.pagination = true; options.pageSize = inventreeLoad(varName, 25); options.pageList = [25, 50, 100, 250, 'all']; + options.rememberOrder = true; - options.sortable = true; - options.search = true; - options.showColumns = true; + + if (options.sortable == null) { + options.sortable = true; + } + + if (options.search == null) { + options.search = true; + } + + if (options.showColumns == null) { + options.showColumns = true; + } // Callback to save pagination data options.onPageChange = function(number, size) { diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index c70ef5c21a..9a86bb98d5 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -777,6 +777,13 @@ class BomList(generics.ListCreateAPIView): if sub_part is not None: queryset = queryset.filter(sub_part=sub_part) + # Filter by "trackable" status of the sub-part + trackable = self.request.query_params.get('trackable', None) + + if trackable is not None: + trackable = str2bool(trackable) + queryset = queryset.filter(sub_part__trackable=trackable) + return queryset permission_classes = [ diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index a5c689a605..548a03ae90 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -8,6 +8,8 @@ from __future__ import unicode_literals from django import forms from django.forms.utils import ErrorDict from django.utils.translation import ugettext as _ +from django.core.validators import MinValueValidator +from django.core.exceptions import ValidationError from mptt.fields import TreeNodeChoiceField @@ -17,6 +19,8 @@ from InvenTree.fields import RoundingDecimalFormField from report.models import TestReport +from part.models import Part + from .models import StockLocation, StockItem, StockItemTracking from .models import StockItemAttachment from .models import StockItemTestResult @@ -271,6 +275,59 @@ class ExportOptionsForm(HelperForm): self.fields['file_format'].choices = self.get_format_choices() +class InstallStockForm(HelperForm): + """ + Form for manually installing a stock item into another stock item + """ + + part = forms.ModelChoiceField( + queryset=Part.objects.all(), + widget=forms.HiddenInput() + ) + + stock_item = forms.ModelChoiceField( + required=True, + queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER), + help_text=_('Stock item to install') + ) + + quantity_to_install = RoundingDecimalFormField( + max_digits=10, decimal_places=5, + initial=1, + label=_('Quantity'), + help_text=_('Stock quantity to assign'), + validators=[ + MinValueValidator(0.001) + ] + ) + + notes = forms.CharField( + required=False, + help_text=_('Notes') + ) + + class Meta: + model = StockItem + fields = [ + 'part', + 'stock_item', + 'quantity_to_install', + 'notes', + ] + + def clean(self): + + data = super().clean() + + stock_item = data.get('stock_item', None) + quantity = data.get('quantity_to_install', None) + + if stock_item and quantity and quantity > stock_item.quantity: + raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')}) + + return data + + class UninstallStockForm(forms.ModelForm): """ Form for uninstalling a stock item which is installed in another item. diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index df1a628f47..b78b0e9410 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -600,12 +600,13 @@ class StockItem(MPTTModel): return self.installedItemCount() > 0 @transaction.atomic - def installIntoStockItem(self, otherItem, user, notes): + def installStockItem(self, otherItem, quantity, user, notes): """ - Install this stock item into another stock item. + Install another stock item into this stock item. Args - otherItem: The stock item to install this item into + otherItem: The stock item to install into this stock item + quantity: The quantity of stock to install user: The user performing the operation notes: Any notes associated with the operation """ @@ -614,18 +615,29 @@ class StockItem(MPTTModel): if self.belongs_to is not None: return False - # TODO - Are there any other checks that need to be performed at this stage? + # If the quantity is less than the stock item, split the stock! + stock_item = otherItem.splitStock(quantity, None, user) - # Mark this stock item as belonging to the other one - self.belongs_to = otherItem - - self.save() + if stock_item is None: + stock_item = otherItem - # Add a transaction note! - self.addTransactionNote( - _('Installed in stock item') + ' ' + str(otherItem.pk), + # Assign the other stock item into this one + stock_item.belongs_to = self + stock_item.save() + + # Add a transaction note to the other item + stock_item.addTransactionNote( + _('Installed into stock item') + ' ' + str(self.pk), user, - notes=notes + notes=notes, + url=self.get_absolute_url() + ) + + # Add a transaction note to this item + self.addTransactionNote( + _('Installed stock item') + ' ' + str(stock_item.pk), + user, notes=notes, + url=stock_item.get_absolute_url() ) @transaction.atomic @@ -645,16 +657,31 @@ class StockItem(MPTTModel): # TODO - Are there any other checks that need to be performed at this stage? + # Add a transaction note to the parent item + self.belongs_to.addTransactionNote( + _("Uninstalled stock item") + ' ' + str(self.pk), + user, + notes=notes, + url=self.get_absolute_url(), + ) + + # Mark this stock item as *not* belonging to anyone self.belongs_to = None self.location = location self.save() + if location: + url = location.get_absolute_url() + else: + url = '' + # Add a transaction note! self.addTransactionNote( _('Uninstalled into location') + ' ' + str(location), user, - notes=notes + notes=notes, + url=url ) @property @@ -838,20 +865,20 @@ class StockItem(MPTTModel): # Do not split a serialized part if self.serialized: - return + return self try: quantity = Decimal(quantity) except (InvalidOperation, ValueError): - return + return self # Doesn't make sense for a zero quantity if quantity <= 0: - return + return self # Also doesn't make sense to split the full amount if quantity >= self.quantity: - return + return self # Create a new StockItem object, duplicating relevant fields # Nullify the PK so a new record is created diff --git a/InvenTree/stock/templates/stock/item_install.html b/InvenTree/stock/templates/stock/item_install.html new file mode 100644 index 0000000000..04798972d2 --- /dev/null +++ b/InvenTree/stock/templates/stock/item_install.html @@ -0,0 +1,17 @@ +{% extends "modal_form.html" %} +{% load i18n %} + +{% block pre_form_content %} + +

+ {% trans "Install another StockItem into this item." %} +

+

+ {% trans "Stock items can only be installed if they meet the following criteria" %}: + +

+

+{% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/templates/stock/item_installed.html b/InvenTree/stock/templates/stock/item_installed.html index cac55c9dce..2a6a0db057 100644 --- a/InvenTree/stock/templates/stock/item_installed.html +++ b/InvenTree/stock/templates/stock/item_installed.html @@ -10,19 +10,7 @@

{% trans "Installed Stock Items" %}


-
-
-
- - -
-
-
- - -
+
{% endblock %} @@ -30,135 +18,14 @@ {{ block.super }} -$('#installed-table').inventreeTable({ - formatNoMatches: function() { - return '{% trans "No stock items installed" %}'; - }, - url: "{% url 'api-stock-list' %}", - queryParams: { - installed_in: {{ item.id }}, - part_detail: true, - }, - name: 'stock-item-installed', - url: "{% url 'api-stock-list' %}", - showColumns: true, - columns: [ - { - checkbox: true, - title: '{% trans 'Select' %}', - searchable: false, - switchable: false, - }, - { - field: 'pk', - title: 'ID', - visible: false, - switchable: false, - }, - { - field: 'part_name', - title: '{% trans "Part" %}', - sortable: true, - formatter: function(value, row, index, field) { - - var url = `/stock/item/${row.pk}/`; - var thumb = row.part_detail.thumbnail; - var name = row.part_detail.full_name; - - html = imageHoverIcon(thumb) + renderLink(name, url); - - return html; - } - }, - { - field: 'IPN', - title: 'IPN', - sortable: true, - formatter: function(value, row, index, field) { - return row.part_detail.IPN; - }, - }, - { - field: 'part_description', - title: '{% trans "Description" %}', - sortable: true, - formatter: function(value, row, index, field) { - return row.part_detail.description; - } - }, - { - field: 'quantity', - title: '{% trans "Stock" %}', - sortable: true, - formatter: function(value, row, index, field) { - - var val = parseFloat(value); - - // If there is a single unit with a serial number, use the serial number - if (row.serial && row.quantity == 1) { - val = '# ' + row.serial; - } else { - val = +val.toFixed(5); - } - - var html = renderLink(val, `/stock/item/${row.pk}/`); - - return html; - } - }, - { - field: 'status', - title: '{% trans "Status" %}', - sortable: 'true', - formatter: function(value, row, index, field) { - return stockStatusDisplay(value); - }, - }, - { - field: 'batch', - title: '{% trans "Batch" %}', - sortable: true, - }, - { - field: 'actions', - switchable: false, - title: '', - formatter: function(value, row) { - var pk = row.pk; - - var html = `
`; - - html += makeIconButton('fa-unlink', 'button-uninstall', pk, '{% trans "Uninstall item" %}'); - - html += `
`; - - return html; - } - } - ], - onLoadSuccess: function() { - - var table = $('#installed-table'); - - // Find buttons and associate actions - table.find('.button-uninstall').click(function() { - var pk = $(this).attr('pk'); - - launchModalForm( - "{% url 'stock-item-uninstall' %}", - { - data: { - 'items[]': [pk], - }, - reload: true, - } - ); - }); - }, - buttons: [ - '#stock-options', - ] -}); +loadInstalledInTable( + $('#installed-table'), + { + stock_item: {{ item.pk }}, + part: {{ item.part.pk }}, + quantity: {{ item.quantity }}, + } +); $('#multi-item-uninstall').click(function() { diff --git a/InvenTree/stock/templates/stock/item_serialize.html b/InvenTree/stock/templates/stock/item_serialize.html index bb0054cca2..0f70647e38 100644 --- a/InvenTree/stock/templates/stock/item_serialize.html +++ b/InvenTree/stock/templates/stock/item_serialize.html @@ -1,6 +1,8 @@ {% extends "modal_form.html" %} +{% load i18n %} {% block pre_form_content %} -Create serialized items from this stock item.
-Select quantity to serialize, and unique serial numbers. +{% trans "Create serialized items from this stock item." %} +
+{% trans "Select quantity to serialize, and unique serial numbers." %} {% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 4c86995cda..7ad8bc4f7f 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -25,6 +25,7 @@ stock_item_detail_urls = [ url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'), url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'), + url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index c09c328c66..9d078bf702 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -683,6 +683,106 @@ class StockItemQRCode(QRCodeView): return None +class StockItemInstall(AjaxUpdateView): + """ + View for manually installing stock items into + a particular stock item. + + In contrast to the StockItemUninstall view, + only a single stock item can be installed at once. + + The "part" to be installed must be provided in the GET query parameters. + + """ + + model = StockItem + form_class = StockForms.InstallStockForm + ajax_form_title = _('Install Stock Item') + ajax_template_name = "stock/item_install.html" + + part = None + + def get_stock_items(self): + """ + Return a list of stock items suitable for displaying to the user. + + Requirements: + - Items must be in stock + + Filters: + - Items can be filtered by Part reference + """ + + items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) + + # Filter by Part association + + # Look at GET params + part_id = self.request.GET.get('part', None) + + if part_id is None: + # Look at POST params + part_id = self.request.POST.get('part', None) + + try: + self.part = Part.objects.get(pk=part_id) + items = items.filter(part=self.part) + except (ValueError, Part.DoesNotExist): + self.part = None + + return items + + def get_initial(self): + + initials = super().get_initial() + + items = self.get_stock_items() + + # If there is a single stock item available, we can use it! + if items.count() == 1: + item = items.first() + initials['stock_item'] = item.pk + initials['quantity_to_install'] = item.quantity + + if self.part: + initials['part'] = self.part + + return initials + + def get_form(self): + + form = super().get_form() + + form.fields['stock_item'].queryset = self.get_stock_items() + + return form + + def post(self, request, *args, **kwargs): + + form = self.get_form() + + valid = form.is_valid() + + if valid: + # We assume by this point that we have a valid stock_item and quantity values + data = form.cleaned_data + + other_stock_item = data['stock_item'] + quantity = data['quantity_to_install'] + notes = data['notes'] + + # Install the other stock item into this one + this_stock_item = self.get_object() + + this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes) + + data = { + 'form_valid': valid, + } + + return self.renderJsonResponse(request, form, data=data) + + class StockItemUninstall(AjaxView, FormMixin): """ View for uninstalling one or more StockItems, diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index dd99c5b611..eb5e4adbf7 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -470,10 +470,16 @@ function loadStockTable(table, options) { if (row.customer) { html += ``; - } else if (row.build_order) { - html += ``; - } else if (row.sales_order) { - html += ``; + } else { + if (row.build_order) { + html += ``; + } else if (row.sales_order) { + html += ``; + } + } + + if (row.belongs_to) { + html += ``; } // Special stock status codes @@ -520,6 +526,9 @@ function loadStockTable(table, options) { } else if (row.customer) { var text = "{% trans "Shipped to customer" %}"; return renderLink(text, `/company/${row.customer}/assigned-stock/`); + } else if (row.sales_order) { + var text = `{% trans "Assigned to sales order" %}`; + return renderLink(text, `/order/sales-order/${row.sales_order}/`); } else if (value) { return renderLink(value, `/stock/location/${row.location}/`); @@ -798,4 +807,301 @@ function createNewStockItem(options) { ]; launchModalForm("{% url 'stock-item-create' %}", options); +} + + +function loadInstalledInTable(table, options) { + /* + * Display a table showing the stock items which are installed in this stock item. + * This is a multi-level tree table, where the "top level" items are Part objects, + * and the children of each top-level item are the associated installed stock items. + * + * The process for retrieving data and displaying the table is as follows: + * + * A) Get BOM data for the stock item + * - It is assumed that the stock item will be for an assembly + * (otherwise why are we installing stuff anyway?) + * - Request BOM items for stock_item.part (and only for trackable sub items) + * + * B) Add parts to table + * - Create rows for each trackable sub-part in the table + * + * C) Gather installed stock item data + * - Get the list of installed stock items via the API + * - If the Part reference is already in the table, add the sub-item as a child + * - If this is a stock item for a *new* part, request that part from the API, + * and add that part as a new row, then add the stock item as a child of that part + * + * D) Enjoy! + * + * + * And the options object contains the following things: + * + * - stock_item: The PK of the master stock_item object + * - part: The PK of the Part reference of the stock_item object + * - quantity: The quantity of the stock item + */ + + function updateCallbacks() { + // Setup callback functions when buttons are pressed + table.find('.button-install').click(function() { + var pk = $(this).attr('pk'); + + launchModalForm( + `/stock/item/${options.stock_item}/install/`, + { + data: { + part: pk, + }, + success: function() { + // Refresh entire table! + table.bootstrapTable('refresh'); + } + } + ); + }); + } + + table.inventreeTable( + { + url: "{% url 'api-bom-list' %}", + queryParams: { + part: options.part, + trackable: true, + sub_part_detail: true, + }, + showColumns: false, + name: 'installed-in', + detailView: true, + detailViewByClick: true, + detailFilter: function(index, row) { + return row.installed_count && row.installed_count > 0; + }, + detailFormatter: function(index, row, element) { + var subTableId = `installed-table-${row.sub_part}`; + + var html = `
`; + + element.html(html); + + var subTable = $(`#${subTableId}`); + + // Display a "sub table" showing all the linked stock items + subTable.bootstrapTable({ + data: row.installed_items, + showHeader: true, + columns: [ + { + field: 'item', + title: '{% trans "Stock Item" %}', + formatter: function(value, subrow, index, field) { + + var pk = subrow.pk; + var html = ''; + + if (subrow.serial && subrow.quantity == 1) { + html += `{% trans "Serial" %}: ${subrow.serial}`; + } else { + html += `{% trans "Quantity" %}: ${subrow.quantity}`; + } + + return renderLink(html, `/stock/item/${subrow.pk}/`); + }, + }, + { + field: 'status', + title: '{% trans "Status" %}', + formatter: function(value, subrow, index, field) { + return stockStatusDisplay(value); + } + }, + { + field: 'batch', + title: '{% trans "Batch" %}', + }, + { + field: 'actions', + title: '', + formatter: function(value, subrow, index) { + + var pk = subrow.pk; + var html = ''; + + // Add some buttons yo! + html += `
`; + + html += makeIconButton('fa-unlink', 'button-uninstall', pk, "{% trans "Uninstall stock item" %}"); + + html += `
`; + + return html; + } + } + ], + onPostBody: function() { + // Setup button callbacks + subTable.find('.button-uninstall').click(function() { + var pk = $(this).attr('pk'); + + launchModalForm( + "{% url 'stock-item-uninstall' %}", + { + data: { + 'items[]': [pk], + }, + success: function() { + // Refresh entire table! + table.bootstrapTable('refresh'); + } + } + ); + }); + } + }); + }, + columns: [ + { + checkbox: true, + title: '{% trans 'Select' %}', + searchable: false, + switchable: false, + }, + { + field: 'pk', + title: 'ID', + visible: false, + switchable: false, + }, + { + field: 'part', + title: '{% trans "Part" %}', + sortable: true, + formatter: function(value, row, index, field) { + + var url = `/part/${row.sub_part}/`; + var thumb = row.sub_part_detail.thumbnail; + var name = row.sub_part_detail.full_name; + + html = imageHoverIcon(thumb) + renderLink(name, url); + + if (row.not_in_bom) { + html = `${html}` + } + + return html; + } + }, + { + field: 'installed', + title: '{% trans "Installed" %}', + sortable: false, + formatter: function(value, row, index, field) { + // Construct a progress showing how many items have been installed + + var installed = row.installed_count || 0; + var required = row.quantity || 0; + + required *= options.quantity; + + var progress = makeProgressBar(installed, required, { + id: row.sub_part.pk, + }); + + return progress; + } + }, + { + field: 'actions', + switchable: false, + formatter: function(value, row) { + var pk = row.sub_part; + + var html = `
`; + + html += makeIconButton('fa-link', 'button-install', pk, '{% trans "Install item" %}'); + + html += `
`; + + return html; + } + } + ], + onLoadSuccess: function() { + // Grab a list of parts which are actually installed in this stock item + + inventreeGet( + "{% url 'api-stock-list' %}", + { + installed_in: options.stock_item, + part_detail: true, + }, + { + success: function(stock_items) { + + var table_data = table.bootstrapTable('getData'); + + stock_items.forEach(function(item) { + + var match = false; + + for (var idx = 0; idx < table_data.length; idx++) { + + var row = table_data[idx]; + + // Check each row in the table to see if this stock item matches + table_data.forEach(function(row) { + + // Match on "sub_part" + if (row.sub_part == item.part) { + + // First time? + if (row.installed_count == null) { + row.installed_count = 0; + row.installed_items = []; + } + + row.installed_count += item.quantity; + row.installed_items.push(item); + + // Push the row back into the table + table.bootstrapTable('updateRow', idx, row, true); + + match = true; + } + + }); + + if (match) { + break; + } + } + + if (!match) { + // The stock item did *not* match any items in the BOM! + // Add a new row to the table... + + // Contruct a new "row" to add to the table + var new_row = { + sub_part: item.part, + sub_part_detail: item.part_detail, + not_in_bom: true, + installed_count: item.quantity, + installed_items: [item], + }; + + table.bootstrapTable('append', [new_row]); + + } + }); + + // Update button callback links + updateCallbacks(); + } + } + ); + + updateCallbacks(); + }, + } + ); } \ No newline at end of file