diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index b4a175a071..b3a1c12e6d 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -1750,6 +1750,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
+ 'STOCK_ENFORCE_BOM_INSTALLATION': {
+ 'name': _('Check BOM when installing items'),
+ 'description': _(
+ 'Installed stock items must exist in the BOM for the parent part'
+ ),
+ 'default': True,
+ 'validator': bool,
+ },
'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Pattern'),
'description': _(
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index 8d062f6855..8d1be0f805 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -584,9 +584,14 @@ class InstallStockItemSerializer(serializers.Serializer):
parent_item = self.context['item']
parent_part = parent_item.part
- # Check if the selected part is in the Bill of Materials of the parent item
- if not parent_part.check_if_part_in_bom(stock_item.part):
- raise ValidationError(_('Selected part is not in the Bill of Materials'))
+ if common.models.InvenTreeSetting.get_setting(
+ 'STOCK_ENFORCE_BOM_INSTALLATION', backup_value=True, cache=False
+ ):
+ # Check if the selected part is in the Bill of Materials of the parent item
+ if not parent_part.check_if_part_in_bom(stock_item.part):
+ raise ValidationError(
+ _('Selected part is not in the Bill of Materials')
+ )
return stock_item
diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html
index 30272d53a9..0443e5eacc 100644
--- a/InvenTree/stock/templates/stock/item.html
+++ b/InvenTree/stock/templates/stock/item.html
@@ -183,7 +183,10 @@
$('#stock-item-install').click(function() {
+ {% settings_value "STOCK_ENFORCE_BOM_INSTALLATION" as enforce_bom %}
+
installStockItem({{ item.pk }}, {{ item.part.pk }}, {
+ enforce_bom: {% js_bool enforce_bom %},
onSuccess: function(response) {
$("#installed-table").bootstrapTable('refresh');
}
diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html
index 388118555c..39bdd51759 100644
--- a/InvenTree/templates/InvenTree/settings/stock.html
+++ b/InvenTree/templates/InvenTree/settings/stock.html
@@ -22,6 +22,7 @@
{% 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" %}
+ {% include "InvenTree/settings/setting.html" with key="STOCK_ENFORCE_BOM_INSTALLATION" icon="fa-check-circle" %}
diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js
index c6a1f5f2de..ddb65c8b5c 100644
--- a/InvenTree/templates/js/translated/stock.js
+++ b/InvenTree/templates/js/translated/stock.js
@@ -3204,7 +3204,7 @@ function installStockItem(stock_item_id, part_id, options={}) {
auto_fill: true,
filters: {
trackable: true,
- in_bom_for: part_id,
+ in_bom_for: options.enforce_bom ? part_id : undefined,
}
},
stock_item: {
diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index 9bdb1b5ef2..5a35fa87cd 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -92,6 +92,7 @@ export enum ApiEndpoints {
stock_merge = 'stock/merge/',
stock_assign = 'stock/assign/',
stock_status = 'stock/status/',
+ stock_install = 'stock/:id/install',
// Order API endpoints
purchase_order_list = 'order/po/',
diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
index e449d08ecb..26ea9f8c79 100644
--- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
@@ -212,7 +212,8 @@ export default function SystemSettings() {
'STOCK_ALLOW_EXPIRED_BUILD',
'STOCK_OWNERSHIP_CONTROL',
'STOCK_LOCATION_DEFAULT_ICON',
- 'STOCK_SHOW_INSTALLED_ITEMS'
+ 'STOCK_SHOW_INSTALLED_ITEMS',
+ 'STOCK_ENFORCE_BOM_INSTALLATION'
]}
/>
)
diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx
index dd09d26483..19e75db4a8 100644
--- a/src/frontend/src/pages/stock/StockDetail.tsx
+++ b/src/frontend/src/pages/stock/StockDetail.tsx
@@ -55,6 +55,7 @@ import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
+import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
@@ -164,6 +165,14 @@ export default function StockDetail() {
type: 'link',
name: 'belongs_to',
label: t`Installed In`,
+ model_formatter: (model: any) => {
+ let text = model?.part_detail?.full_name ?? model?.name;
+ if (model.serial && model.quantity == 1) {
+ text += `# ${model.serial}`;
+ }
+
+ return text;
+ },
model: ModelType.stockitem,
hidden: !stockitem.belongs_to
},
@@ -259,7 +268,8 @@ export default function StockDetail() {
name: 'installed_items',
label: t`Installed Items`,
icon: ,
- hidden: !stockitem?.part_detail?.assembly
+ hidden: !stockitem?.part_detail?.assembly,
+ content:
},
{
name: 'child_items',
diff --git a/src/frontend/src/tables/stock/InstalledItemsTable.tsx b/src/frontend/src/tables/stock/InstalledItemsTable.tsx
new file mode 100644
index 0000000000..b503feaee2
--- /dev/null
+++ b/src/frontend/src/tables/stock/InstalledItemsTable.tsx
@@ -0,0 +1,76 @@
+import { useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { ModelType } from '../../enums/ModelType';
+import { getDetailUrl } from '../../functions/urls';
+import { useTable } from '../../hooks/UseTable';
+import { apiUrl } from '../../states/ApiState';
+import { useUserState } from '../../states/UserState';
+import { TableColumn } from '../Column';
+import { PartColumn, StatusColumn } from '../ColumnRenderers';
+import { InvenTreeTable } from '../InvenTreeTable';
+
+export default function InstalledItemsTable({
+ parentId
+}: {
+ parentId?: number | string;
+}) {
+ const table = useTable('stock_item_install');
+ const user = useUserState();
+ const navigate = useNavigate();
+
+ const tableColumns: TableColumn[] = useMemo(() => {
+ return [
+ {
+ accessor: 'part',
+ switchable: false,
+ render: (record: any) => PartColumn(record?.part_detail)
+ },
+ {
+ accessor: 'quantity',
+ switchable: false,
+ render: (record: any) => {
+ let text = record.quantity;
+
+ if (record.serial && record.quantity == 1) {
+ text = `# ${record.serial}`;
+ }
+
+ return text;
+ }
+ },
+ {
+ accessor: 'batch',
+ switchable: false
+ },
+ StatusColumn(ModelType.stockitem)
+ ];
+ }, []);
+
+ const tableActions = useMemo(() => {
+ return [];
+ }, [user]);
+
+ return (
+ <>
+ {
+ if (record.pk) {
+ navigate(getDetailUrl(ModelType.stockitem, record.pk));
+ }
+ },
+ params: {
+ belongs_to: parentId,
+ part_detail: true
+ }
+ }}
+ />
+ >
+ );
+}