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:
@@ -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 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user