From b27f9263103dcf958222e43f81a4d5bad7e20b6f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 13:51:52 +1100 Subject: [PATCH 01/15] Add ability to filter BOM API by "trackable" status of the sub_part object --- InvenTree/part/api.py | 7 +++++++ 1 file changed, 7 insertions(+) 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 = [ From b467c8a1ef3e53801601711e3da9513e0af1b96a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 15:17:46 +1100 Subject: [PATCH 02/15] Add front-end functions to render an "installed stock" table --- .../static/script/inventree/tables.js | 16 ++- InvenTree/templates/js/stock.html | 110 ++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) 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/templates/js/stock.html b/InvenTree/templates/js/stock.html index dd99c5b611..4ec2507b31 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -798,4 +798,114 @@ 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 + */ + + table.inventreeTable( + { + url: "{% url 'api-bom-list' %}", + queryParams: { + part: options.part, + trackable: true, + sub_part_detail: true, + }, + showColumns: false, + name: 'installed-in', + 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 = `/stock/item/${row.pk}/`; + var thumb = row.sub_part_detail.thumbnail; + var name = row.sub_part_detail.full_name; + + html = imageHoverIcon(thumb) + renderLink(name, url); + + 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 || 0; + var required = row.quantity || 0; + + 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.pk; + + var html = `
`; + + html += makeIconButton('fa-link', 'button-install', pk, '{% trans "Install item" %}'); + + html += `
`; + + return html; + } + } + ], + onLoadSuccess: function() { + console.log('data loaded!'); + } + } + ); } \ No newline at end of file From f04977e7e12dd7f6ba17abf6bbab5a017b1c0ab8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 20:41:28 +1100 Subject: [PATCH 03/15] Add form / view for installing a stock item into another stock item --- InvenTree/stock/forms.py | 28 ++++++++++++++++++++++++++++ InvenTree/stock/urls.py | 1 + InvenTree/stock/views.py | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index a5c689a605..ebcf31cb23 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -8,6 +8,7 @@ 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 mptt.fields import TreeNodeChoiceField @@ -271,6 +272,33 @@ 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 + """ + + stock_item = forms.ModelChoiceField( + required=True, + queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER), + help_text=_('Stock item to install') + ) + + quantity = RoundingDecimalFormField( + max_digits=10, decimal_places=5, + help_text=_('Stock quantity to assign'), + validators=[ + MinValueValidator(0.001) + ] + ) + + class Meta: + model = StockItem + fields = [ + 'stock_item', + 'quantity', + ] + + class UninstallStockForm(forms.ModelForm): """ Form for uninstalling a stock item which is installed in another item. 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..d818e82aa9 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -683,6 +683,46 @@ 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') + + def get_form(self): + + form = super().get_form() + + return form + + def post(self, request, *args, **kwargs): + + + form = self.get_form() + valid = False + + valid = form.is_valid() and valid + + if valid: + pass + + data = { + 'form_valid': valid, + } + + return self.renderJsonResponse(request, form, data=data) + + class StockItemUninstall(AjaxView, FormMixin): """ View for uninstalling one or more StockItems, From fd22e713ff8359749be91e091815ccc1ed95a80c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 20:50:06 +1100 Subject: [PATCH 04/15] Filter available stock items by Part reference --- InvenTree/stock/forms.py | 6 ++++-- InvenTree/stock/views.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index ebcf31cb23..28d0b47ef3 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -283,8 +283,10 @@ class InstallStockForm(HelperForm): help_text=_('Stock item to install') ) - quantity = RoundingDecimalFormField( + quantity_to_install = RoundingDecimalFormField( max_digits=10, decimal_places=5, + initial=1, + label=_('Quantity'), help_text=_('Stock quantity to assign'), validators=[ MinValueValidator(0.001) @@ -295,7 +297,7 @@ class InstallStockForm(HelperForm): model = StockItem fields = [ 'stock_item', - 'quantity', + 'quantity_to_install', ] diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index d818e82aa9..1f6ddcc0f4 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -703,6 +703,22 @@ class StockItemInstall(AjaxUpdateView): form = super().get_form() + queryset = form.fields['stock_item'].queryset + + part = self.request.GET.get('part', None) + + # Filter the available stock items based on the Part reference + if part: + try: + part = Part.objects.get(pk=part) + + queryset = queryset.filter(part=part) + + except (ValueError, Part.DoesNotExist): + pass + + form.fields['stock_item'].queryset = queryset + return form def post(self, request, *args, **kwargs): From a686500df15a5315389d5b63a85a2a611884eb57 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 21:02:20 +1100 Subject: [PATCH 05/15] Calculate initial values for the view --- InvenTree/stock/models.py | 3 ++- InvenTree/stock/views.py | 57 ++++++++++++++++++++++++++++----------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index df1a628f47..62b774dd06 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 installIntoStockItem(self, otherItem, quantity, user, notes): """ Install this stock item into another stock item. Args otherItem: The stock item to install this item into + quantity: The quantity of stock to install user: The user performing the operation notes: Any notes associated with the operation """ diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 1f6ddcc0f4..541c60b2f5 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -699,25 +699,52 @@ class StockItemInstall(AjaxUpdateView): form_class = StockForms.InstallStockForm ajax_form_title = _('Install Stock Item') + 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 + try: + part = self.request.GET.get('part', None) + + print(self.request.GET) + + if part is not None: + part = Part.objects.get(pk=part) + items = items.filter(part=part) + except (ValueError, Part.DoesNotExist): + pass + + 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 + + return initials + def get_form(self): form = super().get_form() - queryset = form.fields['stock_item'].queryset - - part = self.request.GET.get('part', None) - - # Filter the available stock items based on the Part reference - if part: - try: - part = Part.objects.get(pk=part) - - queryset = queryset.filter(part=part) - - except (ValueError, Part.DoesNotExist): - pass - - form.fields['stock_item'].queryset = queryset + form.fields['stock_item'].queryset = self.get_stock_items() return form From 45c888e13d2df97512d688f2e00f84dafc6e7e3c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 21:31:44 +1100 Subject: [PATCH 06/15] Custom cleaning for form Ok, looks like I've been doing this wrong the whole time! The "djangonic" way is pretty cool --- InvenTree/stock/forms.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 28d0b47ef3..729831a9ec 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -9,6 +9,7 @@ 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 @@ -293,13 +294,33 @@ class InstallStockForm(HelperForm): ] ) + notes = forms.CharField( + required=False, + help_text=_('Notes') + ) + class Meta: model = StockItem fields = [ 'stock_item', 'quantity_to_install', + 'notes', ] + def clean(self): + + data = super().clean() + + print("Data:", data) + + stock_item = data['stock_item'] + quantity = data['quantity_to_install'] + + if quantity > stock_item.quantity: + raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')}) + + return data + class UninstallStockForm(forms.ModelForm): """ From 9c27680202da2a0acd9a05c474ac58620b761e1e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 21:32:21 +1100 Subject: [PATCH 07/15] Finish function to install stock item(s) --- InvenTree/stock/models.py | 31 +++++++++++++++++-------------- InvenTree/stock/views.py | 18 ++++++++++++------ 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 62b774dd06..964ab43e8a 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -600,12 +600,12 @@ class StockItem(MPTTModel): return self.installedItemCount() > 0 @transaction.atomic - def installIntoStockItem(self, otherItem, quantity, 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 @@ -615,16 +615,19 @@ 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 + + # Assign the other stock item into this one + stock_item.belongs_to = self + stock_item.save() # Add a transaction note! - self.addTransactionNote( - _('Installed in stock item') + ' ' + str(otherItem.pk), + stock_item.addTransactionNote( + _('Installed into stock item') + ' ' + str(self.pk), user, notes=notes ) @@ -839,20 +842,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/views.py b/InvenTree/stock/views.py index 541c60b2f5..fe2ffbe7c6 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -716,8 +716,6 @@ class StockItemInstall(AjaxUpdateView): try: part = self.request.GET.get('part', None) - print(self.request.GET) - if part is not None: part = Part.objects.get(pk=part) items = items.filter(part=part) @@ -750,14 +748,22 @@ class StockItemInstall(AjaxUpdateView): def post(self, request, *args, **kwargs): - form = self.get_form() - valid = False - valid = form.is_valid() and valid + valid = form.is_valid() if valid: - pass + # 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, From 3c5968ef1ae21ddb3afd3ddc752b0e1cbdcaa28b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 22:58:41 +1100 Subject: [PATCH 08/15] Add subrow table to the "installed items" view Ah, javascript... --- .../stock/templates/stock/item_installed.html | 151 +--------------- InvenTree/templates/js/stock.html | 170 +++++++++++++++++- 2 files changed, 174 insertions(+), 147 deletions(-) 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/templates/js/stock.html b/InvenTree/templates/js/stock.html index 4ec2507b31..330be924e5 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -830,8 +830,25 @@ function loadInstalledInTable(table, options) { * * - 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, + }, + } + ); + }); + } + table.inventreeTable( { url: "{% url 'api-bom-list' %}", @@ -842,6 +859,92 @@ function loadInstalledInTable(table, options) { }, 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 = ''; + + html += row.sub_part_detail.full_name; + html += " | "; + + if (subrow.serial && subrow.quantity == 1) { + html += `{% trans "Serial" %}: ${subrow.serial}`; + } else { + html += `{% trans "Quantity" %}: ${subrow.quantity}`; + } + + return html; + }, + }, + { + field: 'status', + title: '{% trans "Status" %}', + formatter: function(value, subrow, index, field) { + return stockStatusDisplay(value); + } + }, + { + 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, @@ -861,7 +964,7 @@ function loadInstalledInTable(table, options) { sortable: true, formatter: function(value, row, index, field) { - var url = `/stock/item/${row.pk}/`; + var url = `/part/${row.sub_part}/`; var thumb = row.sub_part_detail.thumbnail; var name = row.sub_part_detail.full_name; @@ -877,9 +980,11 @@ function loadInstalledInTable(table, options) { formatter: function(value, row, index, field) { // Construct a progress showing how many items have been installed - var installed = row.installed || 0; + var installed = row.installed_count || 0; var required = row.quantity || 0; + required *= options.quantity; + var progress = makeProgressBar(installed, required, { id: row.sub_part.pk, }); @@ -891,7 +996,7 @@ function loadInstalledInTable(table, options) { field: 'actions', switchable: false, formatter: function(value, row) { - var pk = row.sub_part.pk; + var pk = row.sub_part; var html = `
`; @@ -904,8 +1009,63 @@ function loadInstalledInTable(table, options) { } ], onLoadSuccess: function() { - console.log('data loaded!'); - } + // Grab a list of parts which are actually installed in this stock item + + inventreeGet( + "{% url 'api-stock-list' %}", + { + installed_in: options.stock_item, + }, + { + 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; + } + } + }); + + // Update button callback links + updateCallbacks(); + } + } + ); + + updateCallbacks(); + }, } ); } \ No newline at end of file From 824ce6778fed69face76d505be0ffe614153714a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 23:33:20 +1100 Subject: [PATCH 09/15] Progress bar tweaks - If no maximum value supplied, just show the value (and fill to 100% width) --- .../static/script/inventree/inventree.js | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) 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}
`; } From b9291c6705a464ae7b0a1a3327bf7b4db4841dd5 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 23:33:43 +1100 Subject: [PATCH 10/15] Improve transaction note recording for the StockItem model --- InvenTree/stock/models.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 964ab43e8a..b78b0e9410 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -625,11 +625,19 @@ class StockItem(MPTTModel): stock_item.belongs_to = self stock_item.save() - # Add a transaction note! + # 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 @@ -649,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 From 46f459b4c74462e554395225068df13db1934261 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 23:34:02 +1100 Subject: [PATCH 11/15] Better display of stock table --- InvenTree/templates/js/stock.html | 47 +++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index 330be924e5..b3ea0f2baa 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}/`); @@ -844,6 +853,10 @@ function loadInstalledInTable(table, options) { data: { part: pk, }, + success: function() { + // Refresh entire table! + table.bootstrapTable('refresh'); + } } ); }); @@ -895,7 +908,7 @@ function loadInstalledInTable(table, options) { html += `{% trans "Quantity" %}: ${subrow.quantity}`; } - return html; + return renderLink(html, `/stock/item/${subrow.pk}/`); }, }, { @@ -969,6 +982,10 @@ function loadInstalledInTable(table, options) { var name = row.sub_part_detail.full_name; html = imageHoverIcon(thumb) + renderLink(name, url); + + if (row.not_in_bom) { + html = `${html}` + } return html; } @@ -1015,6 +1032,7 @@ function loadInstalledInTable(table, options) { "{% url 'api-stock-list' %}", { installed_in: options.stock_item, + part_detail: true, }, { success: function(stock_items) { @@ -1056,6 +1074,25 @@ function loadInstalledInTable(table, options) { break; } } + + if (!match) { + // The stock item did *not* match any items in the BOM! + // Add a new row to the table... + + console.log("Found an unmatched part! " + item.pk + " -> " + item.part); + + // 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 From 42a75a82382b21f0f8137385fc45ad092fd22e4a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 23:45:52 +1100 Subject: [PATCH 12/15] Add hidden input to the InstallStockForm form - keeps track of "part" object - so we can filter the stock_items queryset if the form validation fails - Is there a more djangonic way of doing this?? --- InvenTree/stock/forms.py | 8 ++++++++ InvenTree/stock/views.py | 22 ++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 729831a9ec..85a520fed5 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -19,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 @@ -278,6 +280,11 @@ 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), @@ -302,6 +309,7 @@ class InstallStockForm(HelperForm): class Meta: model = StockItem fields = [ + 'part', 'stock_item', 'quantity_to_install', 'notes', diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index fe2ffbe7c6..860ef0c74b 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -699,6 +699,8 @@ class StockItemInstall(AjaxUpdateView): form_class = StockForms.InstallStockForm ajax_form_title = _('Install Stock Item') + part = None + def get_stock_items(self): """ Return a list of stock items suitable for displaying to the user. @@ -713,14 +715,19 @@ class StockItemInstall(AjaxUpdateView): items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) # Filter by Part association - try: - part = self.request.GET.get('part', None) - if part is not None: - part = Part.objects.get(pk=part) - items = items.filter(part=part) + # 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): - pass + self.part = None return items @@ -735,6 +742,9 @@ class StockItemInstall(AjaxUpdateView): item = items.first() initials['stock_item'] = item.pk initials['quantity_to_install'] = item.quantity + + if self.part: + initials['part'] = self.part return initials From 852da6d696feac6d3241aaf8741280fbd4cc1194 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 23:48:15 +1100 Subject: [PATCH 13/15] Fix form validation --- InvenTree/stock/forms.py | 8 ++++---- InvenTree/templates/js/stock.html | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 85a520fed5..2502fc1869 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -282,7 +282,7 @@ class InstallStockForm(HelperForm): part = forms.ModelChoiceField( queryset=Part.objects.all(), - widget=forms.HiddenInput + widget=forms.HiddenInput() ) stock_item = forms.ModelChoiceField( @@ -321,10 +321,10 @@ class InstallStockForm(HelperForm): print("Data:", data) - stock_item = data['stock_item'] - quantity = data['quantity_to_install'] + stock_item = data.get('stock_item', None) + quantity = data.get('quantity_to_install', None) - if quantity > stock_item.quantity: + if stock_item and quantity and quantity > stock_item.quantity: raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')}) return data diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index b3ea0f2baa..0ebedbfb6e 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -1078,8 +1078,6 @@ function loadInstalledInTable(table, options) { if (!match) { // The stock item did *not* match any items in the BOM! // Add a new row to the table... - - console.log("Found an unmatched part! " + item.pk + " -> " + item.part); // Contruct a new "row" to add to the table var new_row = { From 3fe08862071824624d7b69dcaf32f077467e683f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 4 Oct 2020 23:49:01 +1100 Subject: [PATCH 14/15] Remove a debug statement --- InvenTree/stock/forms.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 2502fc1869..548a03ae90 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -319,8 +319,6 @@ class InstallStockForm(HelperForm): data = super().clean() - print("Data:", data) - stock_item = data.get('stock_item', None) quantity = data.get('quantity_to_install', None) From 62734c4b7298fc1da248d644890ca225c64728a1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 5 Oct 2020 00:01:01 +1100 Subject: [PATCH 15/15] Add a custom template for the install item form --- .../stock/templates/stock/item_install.html | 17 +++++++++++++++++ .../stock/templates/stock/item_serialize.html | 6 ++++-- InvenTree/stock/views.py | 1 + InvenTree/templates/js/stock.html | 7 ++++--- 4 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 InvenTree/stock/templates/stock/item_install.html 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" %}: + +

    +
  • {% trans "The StockItem links to a Part which is in the BOM for this StockItem" %}
  • +
  • {% trans "The StockItem is currently in stock" %}
  • +
+

+{% endblock %} \ No newline at end of file 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/views.py b/InvenTree/stock/views.py index 860ef0c74b..9d078bf702 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -698,6 +698,7 @@ class StockItemInstall(AjaxUpdateView): model = StockItem form_class = StockForms.InstallStockForm ajax_form_title = _('Install Stock Item') + ajax_template_name = "stock/item_install.html" part = None diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index 0ebedbfb6e..eb5e4adbf7 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -899,9 +899,6 @@ function loadInstalledInTable(table, options) { var pk = subrow.pk; var html = ''; - html += row.sub_part_detail.full_name; - html += " | "; - if (subrow.serial && subrow.quantity == 1) { html += `{% trans "Serial" %}: ${subrow.serial}`; } else { @@ -918,6 +915,10 @@ function loadInstalledInTable(table, options) { return stockStatusDisplay(value); } }, + { + field: 'batch', + title: '{% trans "Batch" %}', + }, { field: 'actions', title: '',