2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-06 17:53:44 +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 ### 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. - [#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. - [#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. - [#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. | | *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. 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 information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v482 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11540
- Add id to the ordering fields of the Parts model - 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' 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' # Filters for linked 'sub_part'
sub_part_active = rest_filters.BooleanFilter( sub_part_active = rest_filters.BooleanFilter(
label=_('Component part is active'), field_name='sub_part__active' label=_('Component part is active'), field_name='sub_part__active'
@@ -1406,7 +1410,11 @@ class BomOutputOptions(OutputConfiguration):
class BomList( class BomList(
BomMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateDestroyAPIView BomMixin,
BulkUpdateMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
): ):
"""API endpoint for accessing a list of BomItem objects. """API endpoint for accessing a list of BomItem objects.
+69 -7
View File
@@ -1,23 +1,28 @@
import { t } from '@lingui/core/macro'; 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 { useCallback, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import useTable from '@lib/hooks/UseTable'; 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 { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables'; import type { RowAction, TableColumn } from '@lib/types/Tables';
import { IconReplace } from '@tabler/icons-react';
import { formatDecimal } from '../../defaults/formatters'; import { formatDecimal } from '../../defaults/formatters';
import { bomItemFields } from '../../forms/BomForms'; import { bomItemFields } from '../../forms/BomForms';
import { useEditApiFormModal } from '../../hooks/UseForm'; import {
useBulkEditApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { import {
DescriptionColumn, DescriptionColumn,
IPNColumn, IPNColumn,
PartColumn, PartColumn,
ReferenceColumn ReferenceColumn,
RenderPartColumn
} from '../ColumnRenderers'; } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
@@ -85,6 +90,11 @@ export function UsedInTable({
label: t`Inherited`, label: t`Inherited`,
description: t`Show inherited items` description: t`Show inherited items`
}, },
{
name: 'part_locked',
label: t`Locked`,
description: t`Show locked assemblies`
},
{ {
name: 'optional', name: 'optional',
label: t`Optional`, label: t`Optional`,
@@ -122,7 +132,7 @@ export function UsedInTable({
return [ return [
RowEditAction({ RowEditAction({
hidden: locked || !user.hasChangeRole(UserRoles.part), hidden: locked || !user.hasChangeRole(UserRoles.bom),
onClick: () => { onClick: () => {
setSelectedBomItem(record); setSelectedBomItem(record);
editBomItem.open(); editBomItem.open();
@@ -133,9 +143,59 @@ export function UsedInTable({
[user] [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 ( return (
<> <>
{editBomItem.modal} {editBomItem.modal}
{bulkReplace.modal}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.bom_list)} url={apiUrl(ApiEndpoints.bom_list)}
tableState={table} tableState={table}
@@ -147,10 +207,12 @@ export function UsedInTable({
part_detail: true, part_detail: true,
sub_part_detail: true sub_part_detail: true
}, },
enableSelection: user.hasChangeRole(UserRoles.bom),
rowActions: rowActions, rowActions: rowActions,
tableFilters: tableFilters,
modelType: ModelType.part, 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(); 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 }) => { test('Parts - Editing', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/104/details' }); const page = await doCachedLogin(browser, { url: 'part/104/details' });