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 = [