2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-06 03:51:34 +00:00

BOM Enhancements (#10042)

* Add "round_up_multiple" field

* Adjust field def

* Add serializer field

* Update frontend

* Nullify empty numerical values

* Calculate round_up_multiple value

* Adjust table rendering

* Update API version

* Add unit test

* Additional unit test

* Change name of value

* Update BOM docs

* Add new fields

* Add data migration for new fields

* Bug fix for data migration

* Adjust API fields

* Bump API docs

* Update frontend

* Remove old 'overage' field

* Updated BOM docs

* Docs tweak

* Fix required quantity calculation

* additional unit tests

* Tweak BOM table

* Enhanced "can_build" serializer

* Refactor "can_build" calculations

* Code cleanup

* Serializer fix

* Enhanced rendering

* Updated playwright tests

* Fix method name

* Update API unit test

* Refactor 'can_build' calculation

- Make it much more efficient
- Reduce code duplication

* Fix unit test

* Adjust serializer type

* Update src/backend/InvenTree/part/models.py

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

* Update src/backend/InvenTree/part/test_bom_item.py

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

* Update docs/docs/manufacturing/bom.md

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

* Update docs/docs/manufacturing/bom.md

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

* Adjust unit test

* Adjust tests

* Tweak requirements

* Tweak playwright tests

* More playwright fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver
2025-07-20 19:14:29 +10:00
committed by GitHub
parent 31d4a88f90
commit 69ca942dfc
23 changed files with 819 additions and 394 deletions

View File

@@ -99,8 +99,12 @@ export function ApiFormField({
);
// Coerce the value to a numerical value
const numericalValue: number | '' = useMemo(() => {
let val: number | '' = 0;
const numericalValue: number | null = useMemo(() => {
let val: number | null = 0;
if (value == null) {
return null;
}
switch (definition.field_type) {
case 'integer':
@@ -116,7 +120,7 @@ export function ApiFormField({
}
if (Number.isNaN(val) || !Number.isFinite(val)) {
val = '';
val = null;
}
return val;
@@ -198,10 +202,16 @@ export function ApiFormField({
ref={field.ref}
id={fieldId}
aria-label={`number-field-${field.name}`}
value={numericalValue}
value={numericalValue === null ? '' : numericalValue}
error={definition.error ?? error?.message}
decimalScale={definition.field_type == 'integer' ? 0 : 10}
onChange={(value: number | string | null) => onChange(value)}
onChange={(value: number | string | null) => {
if (value != null && value.toString().trim() === '') {
onChange(null);
} else {
onChange(value);
}
}}
step={1}
/>
);

View File

@@ -29,12 +29,14 @@ export function bomItemFields(): ApiFormFieldSet {
},
quantity: {},
reference: {},
overage: {},
note: {},
setup_quantity: {},
attrition: {},
rounding_multiple: {},
allow_variants: {},
inherited: {},
consumable: {},
optional: {}
optional: {},
note: {}
};
}

View File

@@ -140,14 +140,73 @@ export function BomTable({
const units = record.sub_part_detail?.units;
return (
<Group justify='space-between' grow>
<Text>{quantity}</Text>
{record.overage && <Text size='xs'>+{record.overage}</Text>}
{units && <Text size='xs'>{units}</Text>}
<Group justify='space-between'>
<Group gap='xs'>
<Text>{quantity}</Text>
{record.setup_quantity && record.setup_quantity > 0 && (
<Text size='xs'>{`(+${record.setup_quantity})`}</Text>
)}
{record.attrition && record.attrition > 0 && (
<Text size='xs'>{`(+${record.attrition}%)`}</Text>
)}
</Group>
{units && <Text size='xs'>[{units}]</Text>}
</Group>
);
}
},
{
accessor: 'setup_quantity',
defaultVisible: false,
sortable: true,
render: (record: any) => {
const setup_quantity = record.setup_quantity;
const units = record.sub_part_detail?.units;
if (setup_quantity == null || setup_quantity === 0) {
return '-';
} else {
return (
<Group gap='xs' justify='space-between'>
<Text size='xs'>{formatDecimal(setup_quantity)}</Text>
{units && <Text size='xs'>[{units}]</Text>}
</Group>
);
}
}
},
{
accessor: 'attrition',
defaultVisible: false,
sortable: true,
render: (record: any) => {
const attrition = record.attrition;
if (attrition == null || attrition === 0) {
return '-';
} else {
return <Text size='xs'>{`${formatDecimal(attrition)}%`}</Text>;
}
}
},
{
accessor: 'rounding_multiple',
defaultVisible: false,
sortable: false,
render: (record: any) => {
const units = record.sub_part_detail?.units;
const multiple: number | null = record.round_up_multiple;
if (multiple == null) {
return '-';
} else {
return (
<Group gap='xs' justify='space-between'>
<Text>{formatDecimal(multiple)}</Text>
{units && <Text size='xs'>[{units}]</Text>}
</Group>
);
}
}
},
{
accessor: 'substitutes',
defaultVisible: false,

View File

@@ -392,14 +392,48 @@ export default function BuildLineTable({
defaultVisible: false,
switchable: false,
render: (record: any) => {
// Include information about the BOM item (if available)
const extra: any[] = [];
if (record?.bom_item_detail?.setup_quantity) {
extra.push(
<Text key='setup-quantity' size='sm'>
{t`Setup Quantity`}: {record.bom_item_detail.setup_quantity}
</Text>
);
}
if (record?.bom_item_detail?.attrition) {
extra.push(
<Text key='attrition' size='sm'>
{t`Attrition`}: {record.bom_item_detail.attrition}%
</Text>
);
}
if (record?.bom_item_detail?.rounding_multiple) {
extra.push(
<Text key='rounding-multiple' size='sm'>
{t`Rounding Multiple`}:{' '}
{record.bom_item_detail.rounding_multiple}
</Text>
);
}
// If a build output is specified, use the provided quantity
return (
<Group justify='space-between' wrap='nowrap'>
<Text>{record.requiredQuantity}</Text>
{record?.part_detail?.units && (
<Text size='xs'>[{record.part_detail.units}]</Text>
)}
</Group>
<TableHoverCard
title={t`BOM Information`}
extra={extra}
value={
<Group justify='space-between' wrap='nowrap'>
<Text>{record.requiredQuantity}</Text>
{record?.part_detail?.units && (
<Text size='xs'>[{record.part_detail.units}]</Text>
)}
</Group>
}
/>
);
}
},

View File

@@ -234,6 +234,8 @@ test('Build Order - Allocation', async ({ browser }) => {
await page.getByText('Reel Storage').waitFor();
await page.getByText('R_10K_0805_1%').first().click();
await page.reload();
// The capacitor stock should be fully allocated
const cell = await page.getByRole('cell', { name: /C_1uF_0805/ });
const row = await getRowFromCell(cell);
@@ -278,7 +280,7 @@ test('Build Order - Allocation', async ({ browser }) => {
{
name: 'Blue Widget',
ipn: 'widget.blue',
available: '39',
available: '129',
required: '5',
allocated: '5'
},
@@ -313,7 +315,7 @@ test('Build Order - Allocation', async ({ browser }) => {
// Check for expected buttons on Red Widget
const redWidget = await page.getByRole('cell', { name: 'Red Widget' });
const redRow = await redWidget.locator('xpath=ancestor::tr').first();
const redRow = await getRowFromCell(redWidget);
await redRow.getByLabel(/row-action-menu-/i).click();
await page
@@ -426,3 +428,33 @@ test('Build Order - External', async ({ browser }) => {
await page.getByRole('cell', { name: 'PO0017' }).waitFor();
await page.getByRole('cell', { name: 'PO0018' }).waitFor();
});
test('Build Order - BOM Quantity', async ({ browser }) => {
// Validate required build order quantities (based on BOM values)
const page = await doCachedLogin(browser, { url: 'part/81/bom' });
// Expected quantity values for the BOM items
await page.getByText('15(+50)').waitFor();
await page.getByText('10(+100)').waitFor();
await loadTab(page, 'Part Details');
// Expected "can build" value: 13
const canBuild = await page
.getByRole('cell', { name: 'Can Build' })
.locator('div');
const row = await getRowFromCell(canBuild);
await row.getByText('13').waitFor();
await loadTab(page, 'Build Orders');
await page.getByRole('cell', { name: 'BO0016' }).click();
await loadTab(page, 'Required Parts');
const line = await page
.getByRole('cell', { name: 'Thumbnail R_10K_0805_1%' })
.locator('div');
const row2 = await getRowFromCell(line);
await row2.getByText('1175').waitFor();
});

View File

@@ -207,15 +207,17 @@ test('Stock - Serialize', async ({ browser }) => {
await page.getByLabel('text-field-serial_numbers').fill('200-250');
await page.getByRole('button', { name: 'Submit' }).click();
await page
.getByText('Group range 200-250 exceeds allowed quantity')
.getByText('Number of unique serial numbers (51) must match quantity (100)')
.waitFor();
await page.getByLabel('text-field-serial_numbers').fill('1, 2, 3');
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
await page
.getByText('Number of unique serial numbers (3) must match quantity (10)')
.getByText('Number of unique serial numbers (3) must match quantity (100)')
.waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();