2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-06 20:11:37 +00:00

[Refactor] BOM Validation (#10056)

* Add "bom_validated" field to the Part model

* Check bom validity of any assemblies when a part is changed

* Improved update logic

* Fixes for circular imports

* Add additional info to BOM validation serializer

* More intelligent caching

* Refactor

* Update API filter

* Data migration to process existing BomItem entries

* Add "BOM Valid" filter to part table

* Add dashboard widget

* Display BOM validation status

* Tweak dashboard widget

* Update BomTable

* Allow locked BOM items to be validated

* Adjust get_item_hash

- preserve "some" backwards compatibility

* Bump API version

* Refactor app URL patterns

* Fix import sequence

* Tweak imports

* Fix logging message

* Fix error message

* Update src/backend/InvenTree/part/migrations/0141_auto_20250722_0303.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update exception handling

* Try info level debug

* Disable exchange rate update

* Add registry ready flag

* Add is_ready func

* Cleaner init code

* Protect against plugin access until ready

* Fix dashboard widget filter

* Adjust unit test

* Fix receiver name

* Only add plugin URLs if registry is ready

* Cleanup code

* Update playwright tests

* Update docs

* Revert changes to urls.py

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver
2025-07-23 20:16:00 +10:00
committed by GitHub
parent 20477fbfcc
commit dfd9fe44a4
18 changed files with 641 additions and 168 deletions

View File

@@ -33,8 +33,18 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
modelType: ModelType.partcategory,
params: { starred: true }
}),
QueryCountDashboardWidget({
label: 'invalid-bom',
title: t`Invalid BOMs`,
description: t`Assemblies requiring bill of materials validation`,
modelType: ModelType.part,
params: {
active: true, // Only show active parts
assembly: true, // Only show parts which are assemblies
bom_valid: false // Only show parts with invalid BOMs
}
}),
// TODO: 'latest parts'
// TODO: 'BOM waiting validation'
// TODO: 'recently updated stock'
QueryCountDashboardWidget({
title: t`Low Stock`,

View File

@@ -95,7 +95,7 @@ function QueryCountWidget({
}, [query.isFetching, query.isError, query.data]);
return (
<Anchor href='#' onClick={onFollowLink}>
<Anchor href='#' onClick={onFollowLink} underline='never'>
<Group
gap='xs'
wrap='nowrap'

View File

@@ -1,9 +1,13 @@
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Alert,
Center,
Grid,
Group,
HoverCard,
Loader,
type MantineColor,
Skeleton,
Stack,
Text
@@ -11,11 +15,14 @@ import {
import {
IconBookmarks,
IconBuilding,
IconCircleCheck,
IconClipboardList,
IconCurrencyDollar,
IconExclamationCircle,
IconInfoCircle,
IconLayersLinked,
IconList,
IconListCheck,
IconListTree,
IconLock,
IconPackages,
@@ -38,6 +45,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { ActionButton } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
@@ -66,6 +74,7 @@ import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderPart } from '../../components/render/Part';
import { RenderUser } from '../../components/render/User';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
@@ -75,6 +84,7 @@ import {
useFindSerialNumberForm
} from '../../forms/StockForms';
import {
useApiFormModal,
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
@@ -179,6 +189,14 @@ export default function PartDetail() {
refetchOnMount: true
});
const { instance: bomInformation, instanceQuery: bomInformationQuery } =
useInstance({
endpoint: ApiEndpoints.bom_validate,
pk: id,
hasPrimaryKey: true,
refetchOnMount: true
});
const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
@@ -657,6 +675,101 @@ export default function PartDetail() {
partRequirements
]);
const validateBom = useApiFormModal({
url: ApiEndpoints.bom_validate,
method: 'PUT',
fields: {
valid: {
hidden: true,
value: true
}
},
title: t`Validate BOM`,
pk: id,
preFormContent: (
<Alert color='green' icon={<IconCircleCheck />} title={t`Validate BOM`}>
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
</Alert>
),
successMessage: t`BOM validated`,
onFormSuccess: () => {
bomInformationQuery.refetch();
}
});
// Display information about the "validation" state of the BOM for this assembly
const bomValidIcon: ReactNode = useMemo(() => {
if (bomInformationQuery.isFetching) {
return <Loader size='sm' />;
}
let icon: ReactNode;
let color: MantineColor;
let title = '';
let description = '';
if (bomInformation?.bom_validated) {
color = 'green';
icon = <IconListCheck />;
title = t`BOM Validated`;
description = t`The Bill of Materials for this part has been validated`;
} else if (bomInformation?.bom_checked_date) {
color = 'yellow';
icon = <IconExclamationCircle />;
title = t`BOM Not Validated`;
description = t`The Bill of Materials for this part has previously been checked, but requires revalidation`;
} else {
color = 'red';
icon = <IconExclamationCircle />;
title = t`BOM Not Validated`;
description = t`The Bill of Materials for this part has not yet been validated`;
}
return (
<Group gap='xs' justify='flex-end'>
{!bomInformation.bom_validated && (
<ActionButton
icon={<IconCircleCheck />}
color='green'
tooltip={t`Validate BOM`}
onClick={validateBom.open}
/>
)}
<HoverCard position='bottom-end'>
<HoverCard.Target>
<ActionIcon
color={color}
variant='transparent'
aria-label='bom-validation-info'
>
{icon}
</ActionIcon>
</HoverCard.Target>
<HoverCard.Dropdown>
<Alert color={color} icon={icon} title={title}>
<Stack gap='xs'>
<Text size='sm'>{description}</Text>
{bomInformation?.bom_checked_date && (
<Text size='sm'>
{t`Validated On`}: {bomInformation.bom_checked_date}
</Text>
)}
{bomInformation?.bom_checked_by_detail && (
<Group gap='xs'>
<Text size='sm'>{t`Validated By`}: </Text>
<RenderUser
instance={bomInformation.bom_checked_by_detail}
/>
</Group>
)}
</Stack>
</Alert>
</HoverCard.Dropdown>
</HoverCard>
</Group>
);
}, [bomInformation, bomInformationQuery.isFetching]);
// Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => {
return [
@@ -712,6 +825,7 @@ export default function PartDetail() {
{
name: 'bom',
label: t`Bill of Materials`,
controls: bomValidIcon,
icon: <IconListTree />,
hidden: !part.assembly,
content: part?.pk ? (
@@ -818,7 +932,15 @@ export default function PartDetail() {
model_id: part?.pk
})
];
}, [id, part, user, globalSettings, userSettings, detailsPanel]);
}, [
id,
part,
user,
bomValidIcon,
globalSettings,
userSettings,
detailsPanel
]);
const breadcrumbs = useMemo(() => {
return [
@@ -1065,6 +1187,7 @@ export default function PartDetail() {
<>
{editPart.modal}
{deletePart.modal}
{validateBom.modal}
{duplicatePart.modal}
{orderPartsWizard.wizard}
{findBySerialNumber.modal}

View File

@@ -1,9 +1,10 @@
import { t } from '@lingui/core/macro';
import { Alert, Group, Stack, Text } from '@mantine/core';
import { ActionIcon, Alert, Group, Stack, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import {
IconArrowRight,
IconCircleCheck,
IconExclamationCircle,
IconFileArrowLeft,
IconLock,
IconSwitch3
@@ -34,7 +35,6 @@ import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { bomItemFields, useEditBomSubstitutesForm } from '../../forms/BomForms';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import {
useApiFormModal,
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
@@ -105,17 +105,26 @@ export function BomTable({
return (
part && (
<TableHoverCard
value={
<Thumbnail
src={part.thumbnail || part.image}
alt={part.description}
text={part.full_name}
/>
}
extra={extra}
title={t`Part Information`}
/>
<Group gap='xs' justify='space-between' wrap='nowrap'>
<TableHoverCard
value={
<Thumbnail
src={part.thumbnail || part.image}
alt={part.description}
text={part.full_name}
/>
}
extra={extra}
title={t`Part Information`}
/>
{!record.validated && (
<Tooltip label={t`This BOM item has not been validated`}>
<ActionIcon color='red' variant='transparent' size='sm'>
<IconExclamationCircle />
</ActionIcon>
</Tooltip>
)}
</Group>
)
);
}
@@ -499,26 +508,6 @@ export function BomTable({
}
});
const validateBom = useApiFormModal({
url: ApiEndpoints.bom_validate,
method: 'PUT',
fields: {
valid: {
hidden: true,
value: true
}
},
title: t`Validate BOM`,
pk: partId,
preFormContent: (
<Alert color='green' icon={<IconCircleCheck />} title={t`Validate BOM`}>
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
</Alert>
),
successMessage: t`BOM validated`,
onFormSuccess: () => table.refreshTable()
});
const validateBomItem = useCallback((record: any) => {
const url = apiUrl(ApiEndpoints.bom_item_validate, record.pk);
@@ -608,13 +597,6 @@ export function BomTable({
icon={<IconFileArrowLeft />}
onClick={() => importBomItem.open()}
/>,
<ActionButton
key='validate-bom'
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
tooltip={t`Validate BOM`}
icon={<IconCircleCheck />}
onClick={() => validateBom.open()}
/>,
<AddItemButton
key='add-bom-item'
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
@@ -629,7 +611,6 @@ export function BomTable({
{importBomItem.modal}
{newBomItem.modal}
{editBomItem.modal}
{validateBom.modal}
{deleteBomItem.modal}
{editSubstitues.modal}
<Stack gap='xs'>

View File

@@ -201,6 +201,12 @@ function partTableFilters(): TableFilter[] {
description: t`Filter by assembly attribute`,
type: 'boolean'
},
{
name: 'bom_valid',
label: t`BOM Valid`,
description: t`Filter by parts with a valid BOM`,
type: 'boolean'
},
{
name: 'cascade',
label: t`Include Subcategories`,

View File

@@ -4,7 +4,8 @@ import {
clickOnRowMenu,
getRowFromCell,
loadTab,
navigate
navigate,
setTableChoiceFilter
} from '../helpers';
import { doCachedLogin } from '../login';
@@ -81,10 +82,32 @@ test('Parts - Supplier Parts', async ({ browser }) => {
});
test('Parts - BOM', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/87/bom' });
const page = await doCachedLogin(browser, {
url: 'part/category/index/parts'
});
// Display all active assemblies with validated BOMs
await clearTableFilters(page);
await setTableChoiceFilter(page, 'assembly', 'Yes');
await setTableChoiceFilter(page, 'active', 'Yes');
await setTableChoiceFilter(page, 'BOM Valid', 'Yes');
await page.getByText('1 - 12 / 12').waitFor();
// Navigate to BOM for a particular assembly
await navigate(page, 'part/87/bom');
await loadTab(page, 'Bill of Materials');
await page.waitForLoadState('networkidle');
// Mouse-hover to display BOM validation info for this assembly
await page.getByRole('button', { name: 'bom-validation-info' }).hover();
await page
.getByText('The Bill of Materials for this part has been validated')
.waitFor();
await page.getByText('Validated On: 2025-07-23').waitFor();
await page.getByText('Robert Shuruncle').waitFor();
// Move the mouse away
await page.getByRole('link', { name: 'Bill of Materials' }).hover();
const cell = await page.getByRole('cell', {
name: 'Small plastic enclosure, black',