2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-06 09:43:38 +00:00

[feature] Bulk replace (#11861)

* Allow bulk replace of BOM items

* Add "locked" filter for UsedIn table

* Add playwright tests

* docs

* Bump API version

* Update CHANGELOG

* Update api_version.py

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2026-05-04 23:13:23 +10:00
committed by GitHub
parent 118bc63b6b
commit 00d6f1c3ab
7 changed files with 125 additions and 9 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#11861](https://github.com/inventree/InvenTree/pull/11861) adds support for bulk-replacing a component in multiple BOMs simultaneously
- [#11853](https://github.com/inventree/InvenTree/pull/11853) adds BOM comparison functionality, allowing users to compare the BOM of one assembly with another assembly.
- [#11809](https://github.com/inventree/InvenTree/pull/11809) adds multi-level subassembly display mode to the BOM table, allowing users to view multiple levels of subassemblies in a single table view. This is an optional display mode which can be toggled on or off by the user.
- [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog.
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+14
View File
@@ -192,3 +192,17 @@ When comparing BOMs from two different assemblies, the user can select from the
| *Show common parts* | Display only the BOM line items which are common between the two assemblies. |
In each case, any differences between the BOM line items are highlighted in red.
## Replacing Components
When a component is used in the BOM for multiple assemblies, it can be time consuming to update the BOM for each assembly when a change is required. InvenTree provides a "Replace Component" function which streamlines the process of replacing a component part with another part across multiple BOMs.
To replace a component part within multiple assemblies:
- Navigate to the [Used In](../part/views.md#used-in) tab of the component part detail page
- Select the assemblies you wish to update by ticking the checkbox next to each assembly
- Click on the {{ icon("replace", color="blue", title="Replace Component") }} icon to open the "Replace Component" dialog
The following dialog will be displayed, which allows the user to select a new component part to replace the existing component part in the BOM of the selected assemblies:
{{ image("build/replace_component.png", "Replace Component") }}
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 482
INVENTREE_API_VERSION = 483
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v483 -> 2026-05-04 : https://github.com/inventree/InvenTree/pull/11861
- Enable bulk-update operations on the BomItem API endpoint, allowing multiple BOM items to be updated in a single API call
v482 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11540
- Add id to the ordering fields of the Parts model
+9 -1
View File
@@ -1290,6 +1290,10 @@ class BomFilter(FilterSet):
label=_('Assembly part is testable'), field_name='part__testable'
)
part_locked = rest_filters.BooleanFilter(
label=_('Assembly part is locked'), field_name='part__locked'
)
# Filters for linked 'sub_part'
sub_part_active = rest_filters.BooleanFilter(
label=_('Component part is active'), field_name='sub_part__active'
@@ -1406,7 +1410,11 @@ class BomOutputOptions(OutputConfiguration):
class BomList(
BomMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateDestroyAPIView
BomMixin,
BulkUpdateMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
):
"""API endpoint for accessing a list of BomItem objects.
+69 -7
View File
@@ -1,23 +1,28 @@
import { t } from '@lingui/core/macro';
import { Group, Text } from '@mantine/core';
import { Alert, Divider, Group, Stack, Text } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import useTable from '@lib/hooks/UseTable';
import { RowEditAction, UserRoles } from '@lib/index';
import { ActionButton, RowEditAction, UserRoles } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import { IconReplace } from '@tabler/icons-react';
import { formatDecimal } from '../../defaults/formatters';
import { bomItemFields } from '../../forms/BomForms';
import { useEditApiFormModal } from '../../hooks/UseForm';
import {
useBulkEditApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useUserState } from '../../states/UserState';
import {
DescriptionColumn,
IPNColumn,
PartColumn,
ReferenceColumn
ReferenceColumn,
RenderPartColumn
} from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
@@ -85,6 +90,11 @@ export function UsedInTable({
label: t`Inherited`,
description: t`Show inherited items`
},
{
name: 'part_locked',
label: t`Locked`,
description: t`Show locked assemblies`
},
{
name: 'optional',
label: t`Optional`,
@@ -122,7 +132,7 @@ export function UsedInTable({
return [
RowEditAction({
hidden: locked || !user.hasChangeRole(UserRoles.part),
hidden: locked || !user.hasChangeRole(UserRoles.bom),
onClick: () => {
setSelectedBomItem(record);
editBomItem.open();
@@ -133,9 +143,59 @@ export function UsedInTable({
[user]
);
const bulkReplaceParts = useMemo(() => {}, [table.selectedRecords]);
const bulkReplace = useBulkEditApiFormModal({
url: ApiEndpoints.bom_list,
items: table.selectedIds,
title: t`Replace Component`,
submitText: t`Replace`,
preFormContent: (
<Stack gap='xs'>
<Alert
color='orange'
icon={<IconReplace />}
title={t`Replace Component`}
mb='md'
>
<Text>{t`This action cannot be easily undone, so please ensure you have selected the correct assemblies.`}</Text>
</Alert>
<Text>{t`The selected assemblies will be updated with the new component.`}</Text>
{table.selectedRecords.map((record: any) => {
return <RenderPartColumn part={record.part_detail} key={record.pk} />;
})}
<Divider />
</Stack>
),
fields: {
sub_part: {
filters: {
active: true,
component: true
},
default: partId
}
},
onFormSuccess: table.refreshTable
});
const tableActions = useMemo(() => {
return [
<ActionButton
tooltip={t`Replace Component`}
icon={<IconReplace />}
color='blue'
onClick={bulkReplace.open}
hidden={!user.hasChangeRole(UserRoles.bom)}
disabled={!table.selectedIds.length}
/>
];
}, [user, table.selectedIds]);
return (
<>
{editBomItem.modal}
{bulkReplace.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.bom_list)}
tableState={table}
@@ -147,10 +207,12 @@ export function UsedInTable({
part_detail: true,
sub_part_detail: true
},
enableSelection: user.hasChangeRole(UserRoles.bom),
rowActions: rowActions,
tableFilters: tableFilters,
modelType: ModelType.part,
modelField: 'part'
modelField: 'part',
tableActions: tableActions,
tableFilters: tableFilters
}}
/>
</>
+28
View File
@@ -305,6 +305,34 @@ test('Parts - BOM Comparison', async ({ browser }) => {
await page.getByText('Blue Paint', { exact: true }).first().waitFor();
});
test('Parts - Used In', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/4/used_in' });
// Check for expected elements
await page.getByRole('button', { name: 'Assembly Not sorted' }).waitFor();
await page.getByText('R33, R34, R35, R36').waitFor();
// Edit row
const cell = await page.getByRole('cell', { name: 'Thumbnail Test Board 1' });
await cell.click({ button: 'right' });
await page.getByRole('button', { name: 'Edit' }).first().click();
await page.getByRole('textbox', { name: 'text-field-reference' }).waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
// Attempt to replace this part in multiple assemblies
await page.getByRole('checkbox', { name: 'Select all records' }).click();
await page.getByRole('button', { name: 'action-button-replace-' }).click();
await page.getByText('This action cannot be easily undone').waitFor();
// Submit the form - locked parts should throw an error
await page.getByRole('button', { name: 'Replace', exact: true }).click();
await page.getByText('Form Error').waitFor();
await page
.getByText('BOM item cannot be modified - assembly is locked')
.waitFor();
});
test('Parts - Editing', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/104/details' });