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

[UI] BOM compare (#11853)

* Refactor existing components

* Select assembly for comparison

* Rough BOM comparison table

* Select  display mode

* Layout tweaks

* Reset secondary part when drawer is closed

* Responsive grids

* Documentation

* Update CHANGELOG.md

* Add playwright tests

* Update wording

* Allow specification of secondary part with URL search params

* Update URL params when value changes

* Clearer display using icons

* Improve diff  layout

* Adjust playwright tests
This commit is contained in:
Oliver
2026-05-03 12:18:44 +10:00
committed by GitHub
parent f0edb002d0
commit 24ce51c5ca
13 changed files with 764 additions and 219 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#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.
- [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes - [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+15 -78
View File
@@ -4,7 +4,7 @@ title: Bill of Materials
## Bill of Materials ## Bill of Materials
A Bill of Materials (BOM) defines the list of component parts required to make an assembly, [create builds](./build.md) and allocate inventory. A Bill of Materials (BOM) defines the list of component parts required to make an assembly, [create build orders](./build.md) and allocate inventory.
A part which can be built from other sub components is called an *Assembly*. A part which can be built from other sub components is called an *Assembly*.
@@ -169,89 +169,26 @@ If the BOM requires revalidation, the status will be displayed as "Not Validated
{{ image("build/bom_invalid.png", "BOM Not Validated") }} {{ image("build/bom_invalid.png", "BOM Not Validated") }}
## Required Quantity Calculation ## BOM Comparison
When a new [Build Order](./build.md) is created, the required production quantity of each component part is calculated based on the BOM line items defined for the assembly being built. To calculate the required production quantity of a component part, the following considerations are made: It is possible to compare the BOM of one assembly with another assembly. This comparison can highlight different component parts, quantities and other properties of the BOM line items.
### Base Quantity To compare the BOM of one assembly with another, navigate to the "Bill of Materials" tab of the part detail page, then click on the {{ icon("git-compare", color="blue", title="Compare BOM") }} icon at the top of the BOM table:
The base quantity of a BOM line item is defined by the `Quantity` field of the BOM line item. This is the number of parts which are required to build one assembly. This value is multiplied by the number of assemblies which are being built to determine the total quantity of parts required. {{ image("build/bom_compare_icon.png", "BOM Compare") }}
``` This will open the BOM comparison view, which allows you to select a secondary assembly to compare with the primary assembly. The BOM line items of the two assemblies will be displayed side by side, with differences highlighted:
Required Quantity = Base Quantity * Number of Assemblies
```
### Attrition {{ image("build/bom_compare.png", "BOM Compare") }}
The `Attrition` field of a BOM line item is used to account for expected losses during the production process. This is expressed as a percentage of the `Base Quantity` (e.g. 2%). ### Display Mode
If a non-zero attrition percentage is specified, it is applied to the calculated `Required Quantity` value. When comparing BOMs from two different assemblies, the user can select from the following view modes:
``` | View Mode | Description |
Required Quantity = Required Quantity * (1 + Attrition Percentage) | --- | --- |
``` | *Show all parts* | Display all BOM line items from both assemblies. Differences are highlighted. |
| *Show different parts* | Display only the BOM line items which are different between the two assemblies. |
| *Show common parts* | Display only the BOM line items which are common between the two assemblies. |
!!! info "Optional" In each case, any differences between the BOM line items are highlighted in red.
The attrition percentage is optional. If not specified, it defaults to 0%.
### Setup Quantity
The `Setup Quantity` field of a BOM line item is used to account for fixed losses during the production process. This is an additional quantity of the part which is required to ensure that the production run can be completed successfully. This value is added to the calculated `Required Quantity`.
```
Required Quantity = Required Quantity + Setup Quantity
```
!!! info "Optional"
The setup quantity is optional. If not specified, it defaults to 0.
### Rounding Multiple
The `Rounding Multiple` field of a BOM line item is used to round the calculated `Required Quantity` value to the nearest multiple of the specified value. This is useful for ensuring that the required quantity is a whole number, or to meet specific packaging requirements.
```
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
```
!!! info "Optional"
The rounding multiple is optional. If not specified, no rounding is applied to the calculated production quantity.
### Example Calculation
Consider a BOM line item with the following properties:
- Base Quantity: 3
- Attrition: 2% (0.02)
- Setup Quantity: 10
- Rounding Multiple: 25
If we are building 100 assemblies, the required quantity would be calculated as follows:
```
Required Quantity = Base Quantity * Number of Assemblies
= 3 * 100
= 300
Attrition Value = Required Quantity * Attrition Percentage
= 300 * 0.02
= 6
Required Quantity = Required Quantity + Attrition Value
= 300 + 6
= 306
Required Quantity = Required Quantity + Setup Quantity
= 306 + 10
= 316
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
= ceil(316 / 25) * 25
= 13 * 25
= 325
```
So the final required production quantity of the component part would be `325`.
!!! info "Calculation"
The required quantity calculation is performed automatically when a new [Build Order](./build.md) is created.
+90
View File
@@ -0,0 +1,90 @@
---
title: Required Build Quantity
---
## Required Build Quantity
When a new [Build Order](./build.md) is created, the required production quantity of each component part is calculated based on the BOM line items defined for the assembly being built. To calculate the required production quantity of a component part, the following considerations are made:
### Base Quantity
The base quantity of a BOM line item is defined by the `Quantity` field of the BOM line item. This is the number of parts which are required to build one assembly. This value is multiplied by the number of assemblies which are being built to determine the total quantity of parts required.
```
Required Quantity = Base Quantity * Number of Assemblies
```
### Attrition
The `Attrition` field of a BOM line item is used to account for expected losses during the production process. This is expressed as a percentage of the `Base Quantity` (e.g. 2%).
If a non-zero attrition percentage is specified, it is applied to the calculated `Required Quantity` value.
```
Required Quantity = Required Quantity * (1 + Attrition Percentage)
```
!!! info "Optional"
The attrition percentage is optional. If not specified, it defaults to 0%.
### Setup Quantity
The `Setup Quantity` field of a BOM line item is used to account for fixed losses during the production process. This is an additional quantity of the part which is required to ensure that the production run can be completed successfully. This value is added to the calculated `Required Quantity`.
```
Required Quantity = Required Quantity + Setup Quantity
```
!!! info "Optional"
The setup quantity is optional. If not specified, it defaults to 0.
### Rounding Multiple
The `Rounding Multiple` field of a BOM line item is used to round the calculated `Required Quantity` value to the nearest multiple of the specified value. This is useful for ensuring that the required quantity is a whole number, or to meet specific packaging requirements.
```
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
```
!!! info "Optional"
The rounding multiple is optional. If not specified, no rounding is applied to the calculated production quantity.
### Example Calculation
Consider a BOM line item with the following properties:
- Base Quantity: 3
- Attrition: 2% (0.02)
- Setup Quantity: 10
- Rounding Multiple: 25
If we are building 100 assemblies, the required quantity would be calculated as follows:
```
Required Quantity = Base Quantity * Number of Assemblies
= 3 * 100
= 300
Attrition Value = Required Quantity * Attrition Percentage
= 300 * 0.02
= 6
Required Quantity = Required Quantity + Attrition Value
= 300 + 6
= 306
Required Quantity = Required Quantity + Setup Quantity
= 306 + 10
= 316
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
= ceil(316 / 25) * 25
= 13 * 25
= 325
```
So the final required production quantity of the component part would be `325`.
!!! info "Calculation"
The required quantity calculation is performed automatically when a new [Build Order](./build.md) is created.
+1
View File
@@ -150,6 +150,7 @@ nav:
- Bill of Materials: manufacturing/bom.md - Bill of Materials: manufacturing/bom.md
- Build Orders: manufacturing/build.md - Build Orders: manufacturing/build.md
- Build Outputs: manufacturing/output.md - Build Outputs: manufacturing/output.md
- Required Quantity: manufacturing/required.md
- Allocating Stock: manufacturing/allocate.md - Allocating Stock: manufacturing/allocate.md
- External Manufacturing: manufacturing/external.md - External Manufacturing: manufacturing/external.md
- Example Build Order: manufacturing/example.md - Example Build Order: manufacturing/example.md
@@ -13,7 +13,7 @@ v480 -> 2026-04-27 : https://github.com/inventree/InvenTree/pull/11816
- The "issued_by" field on the Build API endpoint is now read-only, and is automatically set to the current user when a build is created - The "issued_by" field on the Build API endpoint is now read-only, and is automatically set to the current user when a build is created
v479 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11723 v479 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11723
- POST /api//notifications/readall/ now requires a POST action - POST /api/notifications/readall/ now requires a POST action
- POST /api/admin/email/test/ - now returns a 200 on. a successful test - POST /api/admin/email/test/ - now returns a 200 on. a successful test
- GET /api/notifications/ - now uses user-centric permissions, not a general read - GET /api/notifications/ - now uses user-centric permissions, not a general read
@@ -71,6 +71,7 @@ export function ApiImage(props: Readonly<ApiImageProps>) {
src={imageUrl} src={imageUrl}
fit='contain' fit='contain'
style={{ style={{
...props.style,
opacity: isLoaded ? 1 : 0, opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.2s ease' transition: 'opacity 0.2s ease'
}} }}
@@ -56,7 +56,7 @@ export function Thumbnail({
w={size} w={size}
fit='contain' fit='contain'
radius='xs' radius='xs'
style={{ maxHeight: size }} style={{ maxHeight: size, height: size }}
/> />
{inner} {inner}
</Group> </Group>
+4 -139
View File
@@ -5,9 +5,7 @@ import {
Center, Center,
Grid, Grid,
Group, Group,
HoverCard,
Loader, Loader,
type MantineColor,
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
@@ -17,13 +15,11 @@ import {
IconBookmarks, IconBookmarks,
IconBuilding, IconBuilding,
IconChecklist, IconChecklist,
IconCircleCheck,
IconClipboardList, IconClipboardList,
IconCurrencyDollar, IconCurrencyDollar,
IconExclamationCircle, IconExclamationCircle,
IconInfoCircle, IconInfoCircle,
IconLayersLinked, IconLayersLinked,
IconListCheck,
IconListDetails, IconListDetails,
IconListTree, IconListTree,
IconLock, IconLock,
@@ -47,7 +43,6 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation'; import { getDetailUrl } from '@lib/functions/Navigation';
import { ActionButton } from '@lib/index';
import type { StockOperationProps } from '@lib/types/Forms'; import type { StockOperationProps } from '@lib/types/Forms';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions'; import { PrintingActions } from '../../components/buttons/PrintingActions';
@@ -76,20 +71,17 @@ import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel'; import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderPart } from '../../components/render/Part'; import { RenderPart } from '../../components/render/Part';
import { RenderUser } from '../../components/render/User';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext'; import { useApi } from '../../contexts/ApiContext';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { usePartFields } from '../../forms/PartForms'; import { usePartFields } from '../../forms/PartForms';
import { useFindSerialNumberForm } from '../../forms/StockForms'; import { useFindSerialNumberForm } from '../../forms/StockForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import { import {
useApiFormModal,
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { type UseInstanceResult, useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions'; import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { import {
useGlobalSettingsState, useGlobalSettingsState,
@@ -112,6 +104,7 @@ import PartAllocationPanel from './PartAllocationPanel';
import PartPricingPanel from './PartPricingPanel'; import PartPricingPanel from './PartPricingPanel';
import PartStockHistoryDetail from './PartStockHistoryDetail'; import PartStockHistoryDetail from './PartStockHistoryDetail';
import PartSupplierDetail from './PartSupplierDetail'; import PartSupplierDetail from './PartSupplierDetail';
import { BomActions } from './bom/BomActions';
/** /**
* Render a part revision selector component * Render a part revision selector component
@@ -154,132 +147,6 @@ function RevisionSelector({
); );
} }
/**
* A hover-over component which displays information about the BOM validation for a given part
*/
function BomValidationInformation({
bomInformation,
partId
}: {
bomInformation: UseInstanceResult;
partId: number;
}) {
const user = useUserState();
const [taskId, setTaskId] = useState<string>('');
useBackgroundTask({
taskId: taskId,
message: t`Validating BOM`,
successMessage: t`BOM validated`,
onComplete: () => {
bomInformation.instanceQuery.refetch();
}
});
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: null,
onFormSuccess: (response: any) => {
// If the process has been offloaded to a background task
if (response.task_id) {
setTaskId(response.task_id);
} else {
bomInformation.instanceQuery.refetch();
}
}
});
if (bomInformation.instanceQuery.isFetching) {
return <Loader size='sm' />;
}
let icon: ReactNode;
let color: MantineColor;
let title = '';
let description = '';
if (bomInformation.instance?.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.instance?.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 (
<>
{validateBom.modal}
<Group gap='xs' justify='flex-end'>
{!bomInformation.instance?.bom_validated &&
user.hasChangeRole(UserRoles.bom) && (
<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.instance?.bom_checked_date && (
<Text size='sm'>
{t`Validated On`}:{' '}
{bomInformation.instance.bom_checked_date}
</Text>
)}
{bomInformation.instance?.bom_checked_by_detail && (
<Group gap='xs'>
<Text size='sm'>{t`Validated By`}: </Text>
<RenderUser
instance={bomInformation.instance.bom_checked_by_detail}
/>
</Group>
)}
</Stack>
</Alert>
</HoverCard.Dropdown>
</HoverCard>
</Group>
</>
);
}
/** /**
* Detail view for a single Part instance * Detail view for a single Part instance
*/ */
@@ -295,6 +162,7 @@ export default function PartDetail() {
const globalSettings = useGlobalSettingsState(); const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState(); const userSettings = useUserSettingsState();
// BOM validation information (used for hover-over info on the BOM tab)
const bomInformation = useInstance({ const bomInformation = useInstance({
endpoint: ApiEndpoints.bom_validate, endpoint: ApiEndpoints.bom_validate,
pk: id, pk: id,
@@ -808,10 +676,7 @@ export default function PartDetail() {
name: 'bom', name: 'bom',
label: t`Bill of Materials`, label: t`Bill of Materials`,
controls: ( controls: (
<BomValidationInformation <BomActions bomInformation={bomInformation} partInstance={part} />
bomInformation={bomInformation}
partId={part.pk ?? -1}
/>
), ),
icon: <IconListTree />, icon: <IconListTree />,
hidden: !part.assembly || !user.hasViewRole(UserRoles.bom), hidden: !part.assembly || !user.hasViewRole(UserRoles.bom),
@@ -0,0 +1,191 @@
import { ActionButton } from '@lib/components/ActionButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Alert,
Group,
HoverCard,
Loader,
type MantineColor,
Stack,
Text
} from '@mantine/core';
import {
IconCircleCheck,
IconExclamationCircle,
IconGitCompare,
IconListCheck
} from '@tabler/icons-react';
import { type ReactNode, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { RenderUser } from '../../../components/render/User';
import useBackgroundTask from '../../../hooks/UseBackgroundTask';
import { useApiFormModal } from '../../../hooks/UseForm';
import type { UseInstanceResult } from '../../../hooks/UseInstance';
import { useUserState } from '../../../states/UserState';
import { BomCompareDrawer } from './BomCompare';
/**
* A hover-over component which displays information about the BOM validation for a given part
*/
export function BomActions({
bomInformation,
partInstance
}: Readonly<{
bomInformation: UseInstanceResult;
partInstance: any;
}>) {
const user = useUserState();
const [bomCompareOpen, setBomCompareOpen] = useState<boolean>(false);
const [bomCompareId, setBomCompareId] = useState<string>('');
const [searchParams, setSearchParams] = useSearchParams();
// Open the BOM compare drawer if the URL contains the relevant query parameter
useEffect(() => {
if (
searchParams.has('compare') &&
!!searchParams.get('compare') &&
!bomCompareOpen
) {
setBomCompareId(searchParams.get('compare') as string);
setBomCompareOpen(true);
}
}, [searchParams]);
const [taskId, setTaskId] = useState<string>('');
useBackgroundTask({
taskId: taskId,
message: t`Validating BOM`,
successMessage: t`BOM validated`,
onComplete: () => {
bomInformation.instanceQuery.refetch();
}
});
const validateBom = useApiFormModal({
url: ApiEndpoints.bom_validate,
method: 'PUT',
fields: {
valid: {
hidden: true,
value: true
}
},
title: t`Validate BOM`,
pk: partInstance.pk,
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: null,
onFormSuccess: (response: any) => {
// If the process has been offloaded to a background task
if (response.task_id) {
setTaskId(response.task_id);
} else {
bomInformation.instanceQuery.refetch();
}
}
});
if (bomInformation.instanceQuery.isFetching) {
return <Loader size='sm' />;
}
let icon: ReactNode;
let color: MantineColor;
let title = '';
let description = '';
if (bomInformation.instance?.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.instance?.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 (
<>
{validateBom.modal}
<Group gap='xs' justify='flex-end'>
<ActionButton
icon={<IconGitCompare />}
color='blue'
tooltip={t`Compare Bill of Materials`}
onClick={() => setBomCompareOpen(true)}
/>
{!bomInformation.instance?.bom_validated &&
user.hasChangeRole(UserRoles.bom) && (
<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.instance?.bom_checked_date && (
<Text size='sm'>
{t`Validated On`}:{' '}
{bomInformation.instance.bom_checked_date}
</Text>
)}
{bomInformation.instance?.bom_checked_by_detail && (
<Group gap='xs'>
<Text size='sm'>{t`Validated By`}: </Text>
<RenderUser
instance={bomInformation.instance.bom_checked_by_detail}
/>
</Group>
)}
</Stack>
</Alert>
</HoverCard.Dropdown>
</HoverCard>
</Group>
<BomCompareDrawer
partInstance={partInstance}
compareId={bomCompareId}
opened={bomCompareOpen}
onClosed={() => {
setBomCompareId('');
setBomCompareOpen(false);
setSearchParams((params: URLSearchParams) => {
params.delete('compare');
return params;
});
}}
/>
</>
);
}
@@ -0,0 +1,428 @@
import { ApiEndpoints, ModelType, StylishText, apiUrl } from '@lib/index';
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Alert,
Divider,
Drawer,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text
} from '@mantine/core';
import {
IconArrowRight,
IconCircleCheck,
IconCirclePlus,
IconCircleX,
IconStatusChange
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../../App';
import { StandaloneField } from '../../../components/forms/StandaloneField';
import Expand from '../../../components/items/Expand';
import { RenderPartColumn } from '../../../tables/ColumnRenderers';
// Field to check for differences when comparing BOM items
const DELTA_FIELDS = {
quantity: t`Quantity`,
reference: t`Reference`,
allow_variants: t`Allow Variants`,
inherited: t`Inherited`,
optional: t`Optional`,
consumable: t`Consumable`,
setup_quantity: t`Setup Quantity`,
attrition: t`Attrition`,
rounding_multiple: t`Rounding Multiple`
};
type BomCompareRow = {
part_detail: any;
primary: any;
secondary: any;
match: boolean;
deltas: string[];
key: string;
};
type BomDisplayMode = 'all' | 'different' | 'common';
function getBomDeltas(primary: any, secondary: any): string[] {
const deltas: string[] = [];
Object.entries(DELTA_FIELDS).forEach(([field, label]) => {
if (primary?.[field] != secondary?.[field]) {
deltas.push(field);
}
});
return deltas;
}
function BomTableRow({
item
}: Readonly<{
item: BomCompareRow;
}>) {
const partMatch = !!item.primary && !!item.secondary;
const quantityMatch =
partMatch && item.primary.quantity == item.secondary.quantity;
const deltas: any[] = useMemo(() => {
const fields: any[] = [];
item.deltas.forEach((delta) => {
fields.push({
field: delta,
label: DELTA_FIELDS[delta as keyof typeof DELTA_FIELDS],
primaryValue: item.primary?.[delta] ?? null,
secondaryValue: item.secondary?.[delta] ?? null
});
});
return fields;
}, [item]);
// Determine the appropriate icon to display for this row
const rowIcon: ReactNode = useMemo(() => {
if (!!item.primary != !!item.secondary) {
if (!!item.secondary) {
// Part was added to the secondary BOM (exists in secondary but not primary)
return (
<ActionIcon
variant='transparent'
size='sm'
color='var(--mantine-color-yellow-5)'
>
<IconCirclePlus />
</ActionIcon>
);
} else {
return (
<ActionIcon
variant='transparent'
size='sm'
color='var(--mantine-color-red-5)'
>
<IconCircleX />
</ActionIcon>
);
}
} else if (
!!item.deltas?.length ||
item.primary?.quantity != item.secondary?.quantity
) {
// Part exists in both BOMs but has differences
return (
<ActionIcon
variant='transparent'
size='sm'
color='var(--mantine-color-blue-5)'
>
<IconStatusChange />
</ActionIcon>
);
} else {
return (
<ActionIcon
variant='transparent'
size='sm'
color='var(--mantine-color-green-5)'
>
<IconCircleCheck />
</ActionIcon>
);
}
}, [item]);
return (
<Table.Tr>
<Table.Td>
<Group gap='xs'>
{rowIcon}
<RenderPartColumn part={item.part_detail} />
</Group>
</Table.Td>
<Table.Td>
<Group gap='xs'>
<Text size='sm'>
{item.primary?.quantity ?? item.secondary?.quantity ?? '-'}
</Text>
{item.part_detail?.units && (
<Text size='xs'>[{item.part_detail.units}]</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Group gap='xs'>
{rowIcon}
{partMatch && deltas.length > 0 ? (
<Stack gap='xs'>
{deltas.map((delta, index) => (
<Group key={delta.field} gap='xs' justify='space-between'>
<Text size='xs'>{delta.label}</Text>
<Text size='xs'>{delta.primaryValue ?? '-'}</Text>
<ActionIcon size='xs' variant='transparent'>
<IconArrowRight />
</ActionIcon>
<Text size='xs'>{delta.secondaryValue ?? '-'}</Text>
</Group>
))}
</Stack>
) : (
<Text size='xs'>
{partMatch
? t`No changes`
: !!item.primary
? t`Part removed from BOM`
: t`Part added to BOM`}
</Text>
)}
</Group>
</Table.Td>
</Table.Tr>
);
}
function BomTable({
items
}: Readonly<{
items: BomCompareRow[];
}>) {
return (
<Paper p='xs' withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t`Part`}</Table.Th>
<Table.Th>{t`Quantity`}</Table.Th>
<Table.Th>{t`Changes`}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item: any, index) => (
<BomTableRow key={index} item={item} />
))}
</Table.Tbody>
</Table>
</Paper>
);
}
export function BomCompareDrawer({
opened,
onClosed,
compareId,
partInstance
}: {
opened: boolean;
onClosed: () => void;
compareId?: string;
partInstance: any;
}) {
const [displayMode, setDisplayMode] = useState<BomDisplayMode>('all');
const [searchParam, setSearchParams] = useSearchParams();
// Fetch entire BOM for the part
const primaryBom = useQuery({
queryKey: ['bom-compare-primary', partInstance.pk, opened],
enabled: opened && !!partInstance.pk,
queryFn: async () => {
return api
.get(apiUrl(ApiEndpoints.bom_list), {
params: {
part: partInstance.pk,
sub_part_detail: true
}
})
.then((response) => response.data);
}
});
// Secondary part ID
const [secondaryPartId, setSecondaryPartId] = useState<string>(
compareId ?? ''
);
useEffect(() => {
setSecondaryPartId(compareId ?? '');
}, [opened]);
// Fetch BOM for the secondary part
const secondaryBom = useQuery({
queryKey: ['bom-compare-secondary', secondaryPartId, opened],
enabled: opened && !!secondaryPartId,
queryFn: async () => {
return api
.get(apiUrl(ApiEndpoints.bom_list), {
params: {
part: secondaryPartId,
sub_part_detail: true
}
})
.then((response) => response.data);
}
});
// Perform comparison against
const comparedItems: any[] = useMemo(() => {
let rows: BomCompareRow[] = [];
const primaryPartIds = new Set();
const secondaryPartIds = new Set();
// First, iterate through the "primary" BOM to generate the initial data
primaryBom.data?.forEach((item: any) => {
let subPartId = `${item.sub_part}`;
while (primaryPartIds.has(subPartId)) {
subPartId += '_dup';
}
primaryPartIds.add(subPartId);
rows.push({
part_detail: item.sub_part_detail,
primary: item,
secondary: null,
match: false,
deltas: getBomDeltas(item, null), // Initialize deltas with all fields (since no match yet)
key: subPartId
});
});
// Next, iterate through the "secondary" BOM to find matches and update the data
secondaryBom.data?.forEach((item: any) => {
let subPartId = `${item.sub_part}`;
while (secondaryPartIds.has(subPartId)) {
subPartId += '_dup';
}
secondaryPartIds.add(subPartId);
// Try to find a matching part in the primary BOM
const match = rows.find((row) => row.key == subPartId);
if (match) {
// If a match is found, update the existing row
match.secondary = item;
match.match = true; // Mark as a match
match.deltas = getBomDeltas(match.primary, match.secondary); // Update deltas with actual differences
} else {
// If no match is found, add a new row for the secondary item
rows.push({
part_detail: item.sub_part_detail,
primary: null,
secondary: item,
match: false,
deltas: getBomDeltas(null, item),
key: subPartId
});
}
});
switch (displayMode) {
case 'different':
// Show only *different* parts
rows = rows.filter((row) => !row.match);
break;
case 'common':
// Show only *common* parts
rows = rows.filter((row) => row.match);
break;
default:
case 'all':
break;
}
// Return rows, sorted by part name
return rows.sort((a, b) => {
const nameA = a.part_detail?.name ?? '';
const nameB = b.part_detail?.name ?? '';
return nameA.localeCompare(nameB);
});
}, [displayMode, primaryBom.data, secondaryBom.data]);
return (
<Drawer
opened={opened}
onClose={onClosed}
withCloseButton
position='bottom'
size='80%'
title={
<StylishText size='lg'>{t`Compare Bill of Materials`}</StylishText>
}
>
<Stack gap='xs'>
<Divider />
<Paper p='xs' withBorder>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Stack gap='xs' justify='flex-start' align='stretch'>
<Text size='sm'>{t`Primary Assembly`}</Text>
<Text
size='xs'
c='dimmed'
>{t`Primary assembly for comparison`}</Text>
<RenderPartColumn part={partInstance} />
</Stack>
<Expand>
<StandaloneField
fieldName='assembly'
fieldDefinition={{
description: t`Select assembly to compare`,
label: t`Secondary Assembly`,
field_type: 'related field',
value: secondaryPartId,
api_url: apiUrl(ApiEndpoints.part_list),
model: ModelType.part,
required: true,
filters: {
assembly: true
},
onValueChange: (value) => {
setSecondaryPartId(value);
if (opened) {
setSearchParams(
{
compare: value
},
{ replace: true }
);
}
}
}}
/>
</Expand>
<Select
label={t`Display Mode`}
aria-label='bom-compare-display-mode'
description={t`Select display mode for BOM comparison`}
defaultValue={'all'}
onChange={(value) => setDisplayMode(value as any)}
data={[
{ value: 'all', label: t`Show all Parts` },
{ value: 'different', label: t`Show different Parts` },
{ value: 'common', label: t`Show common Parts` }
]}
/>
</SimpleGrid>
</Paper>
{secondaryPartId ? (
<BomTable items={comparedItems} />
) : (
<Alert color='yellow'>{t`Select an assembly to view Bill of Materials comparison`}</Alert>
)}
</Stack>
</Drawer>
);
}
+31
View File
@@ -268,6 +268,37 @@ test('Parts - BOM Validation', async ({ browser }) => {
await page.getByText('Validated By: allaccessAlly').waitFor(); await page.getByText('Validated By: allaccessAlly').waitFor();
}); });
test('Parts - BOM Comparison', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/104/bom' });
await page
.getByRole('button', { name: 'action-button-compare-bill-of' })
.click();
await page.getByText('Select an assembly to view').waitFor();
await page
.getByRole('combobox', { name: 'related-field-assembly' })
.fill('blue round table');
await page.getByText('Blue Round TableA round table').click();
await page.getByRole('columnheader', { name: 'Quantity' }).first().waitFor();
await page.getByRole('columnheader', { name: 'Changes' }).first().waitFor();
await page.getByText('No changes').first().waitFor();
await page.getByText('Part added to BOM').first().waitFor();
await page.getByText('Removed from BOM').first().waitFor();
// Change display mode
await page.getByRole('textbox', { name: 'bom-compare-display-mode' }).click();
await page.getByRole('option', { name: 'Show different Parts' }).click();
// Use URL params to compare directly
await navigate(page, 'part/108/bom?compare=107');
await page.waitForLoadState('networkidle');
await page.getByText('0.125').nth(1).waitFor();
await page.getByText('Red Paint', { exact: true }).first().waitFor();
await page.getByText('Blue Paint', { exact: true }).first().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' });