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:
@@ -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`,
|
||||
|
@@ -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'
|
||||
|
@@ -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}
|
||||
|
@@ -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'>
|
||||
|
@@ -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`,
|
||||
|
@@ -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',
|
||||
|
Reference in New Issue
Block a user