diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 08326d6f25..8bcdec7eba 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1467,6 +1467,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': '', }, + 'STOCK_SHOW_INSTALLED_ITEMS': { + 'name': _('Show Installed Stock Items'), + 'description': _('Display installed stock items in stock tables'), + 'default': False, + 'validator': bool, + }, + 'BUILDORDER_REFERENCE_PATTERN': { 'name': _('Build Order Reference Pattern'), 'description': _('Required pattern for generating Build Order reference field'), diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index ba7387885e..356e48feba 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -510,6 +510,15 @@ class StockFilter(rest_filters.FilterSet): else: return queryset.filter(belongs_to=None) + has_installed_items = rest_filters.BooleanFilter(label='Has installed items', method='filter_has_installed') + + def filter_has_installed(self, queryset, name, value): + """Filter stock items by "belongs_to" field being empty.""" + if str2bool(value): + return queryset.filter(installed_items__gt=0) + else: + return queryset.filter(installed_items=0) + sent_to_customer = rest_filters.BooleanFilter(label='Sent to customer', method='filter_sent_to_customer') def filter_sent_to_customer(self, queryset, name, value): diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 2518ca3c35..77293e3542 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -133,14 +133,12 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): model = StockItem fields = [ - 'allocated', 'batch', 'belongs_to', 'build', 'consumed_by', 'customer', 'delete_on_deplete', - 'expired', 'expiry_date', 'is_building', 'link', @@ -158,13 +156,11 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'sales_order', 'sales_order_reference', 'serial', - 'stale', 'status', 'status_text', 'stocktake_date', 'supplier_part', 'supplier_part_detail', - 'tracking_items', 'barcode_hash', 'updated', 'purchase_price', @@ -172,6 +168,13 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'use_pack_size', 'tests', + # Annotated fields + 'allocated', + 'expired', + 'installed_items', + 'stale', + 'tracking_items', + 'tags', ] @@ -282,6 +285,11 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): ) ) + # Annotate with the total number of "installed items" + queryset = queryset.annotate( + installed_items=SubqueryCount('installed_parts') + ) + return queryset status_text = serializers.CharField(source='get_status_display', read_only=True) @@ -295,10 +303,11 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): quantity = InvenTreeDecimalField() # Annotated fields - tracking_items = serializers.IntegerField(read_only=True, required=False) allocated = serializers.FloatField(required=False) expired = serializers.BooleanField(required=False, read_only=True) + installed_items = serializers.IntegerField(read_only=True, required=False) stale = serializers.BooleanField(required=False, read_only=True) + tracking_items = serializers.IntegerField(read_only=True, required=False) purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( label=_('Purchase Price'), diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html index 8beaac4620..b0badca6de 100644 --- a/InvenTree/templates/InvenTree/settings/stock.html +++ b/InvenTree/templates/InvenTree/settings/stock.html @@ -21,6 +21,7 @@ {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_BUILD" icon="fa-tools" %} {% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" icon="fa-users" %} {% include "InvenTree/settings/setting.html" with key="STOCK_LOCATION_DEFAULT_ICON" icon="fa-icons" %} + {% include "InvenTree/settings/setting.html" with key="STOCK_SHOW_INSTALLED_ITEMS" icon="fa-sitemap" %} diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index ffc66816bb..77080a1205 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1724,6 +1724,9 @@ function loadStockTable(table, options) { options.params['location_detail'] = true; options.params['part_detail'] = true; + // Determine if installed items are displayed in the table + let show_installed_items = global_settings.STOCK_SHOW_INSTALLED_ITEMS; + var params = options.params || {}; const filterTarget = options.filterTarget || '#filter-list-stock'; @@ -1769,18 +1772,35 @@ function loadStockTable(table, options) { ]; col = { - field: 'part_detail.full_name', + field: 'part', title: '{% trans "Part" %}', sortName: 'part__name', visible: params['part_detail'], switchable: params['part_detail'], formatter: function(value, row) { - return partDetail(row.part_detail, { + let html = ''; + + if (show_installed_items && row.installed_items > 0) { + if (row.installed_items_received) { + // Data received, ignore + } else if (row.installed_items_requested) { + html += ``; + } else { + html += ` + + + `; + } + } + + html += partDetail(row.part_detail, { thumb: true, link: true, icons: true, }); + + return html; } }; @@ -1791,7 +1811,7 @@ function loadStockTable(table, options) { columns.push(col); col = { - field: 'part_detail.IPN', + field: 'IPN', title: '{% trans "IPN" %}', sortName: 'part__IPN', visible: params['part_detail'], @@ -2164,6 +2184,37 @@ function loadStockTable(table, options) { title: '{% trans "Notes" %}', }); + // Function to request subset of items which are installed *within* a particular item + function requestInstalledItems(stock_item) { + inventreeGet( + '{% url "api-stock-list" %}', + { + belongs_to: stock_item, + part_detail: true, + supplier_detail: true, + }, + { + success: function(response) { + // Add the returned stock items into the table + let row = table.bootstrapTable('getRowByUniqueId', stock_item); + row.installed_items_received = true; + + table.bootstrapTable('updateByUniqueId', stock_item, row, true); + table.bootstrapTable('append', response); + + // Auto-expand the newly added data + $(`.treegrid-${stock_item}`).treegrid('expand'); + }, + error: function(xhr) { + console.error(`Error requesting installed items for ${stock_item}`); + showApiError(xhr); + } + } + ); + } + + let parent_id = 'top-level'; + table.inventreeTable({ method: 'get', formatNoMatches: function() { @@ -2177,6 +2228,35 @@ function loadStockTable(table, options) { showColumns: true, showFooter: true, columns: columns, + treeEnable: show_installed_items, + rootParentId: parent_id, + parentIdField: 'belongs_to', + uniqueId: 'pk', + idField: 'pk', + treeShowField: 'part', + onPostBody: function() { + + if (show_installed_items) { + table.treegrid({ + treeColumn: 1, + }); + + table.treegrid('collapseAll'); + + // Callback for 'load sub-items' button + table.find('.load-sub-items').click(function(event) { + event.preventDefault(); + + let pk = $(this).attr('pk'); + let row = table.bootstrapTable('getRowByUniqueId', pk); + + requestInstalledItems(row.pk); + + row.installed_items_requested = true; + table.bootstrapTable('updateByUniqueId', pk, row, true); + }); + } + } }); var buttons = [