2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-12 03:28:37 +00:00

Support physical units for BOM lines (#11631)

* Add new "raw_amount" field to BomItem model

* Batch process data migration

* Update migration

* Calculate 'quantity' from 'raw_amount' field

* Improve decimal formatting in migration

* Allow raw_amount in serializer

* Adjust frontend form

* API validation and unit tests

* Additional playwright tests

* Update API version and CHANGELOG

* Updated docs

* Fix docstring

* Better handling of empty values

* Tweak unit tests

* Tweak unit test

* Fix unit test

* Adjust form field text

* Adjust migration file

* Tweak playwright tests

* Fix unit test

* Adjust serializers / import-export / playwright

* Fix migration

* Fix validation

* Loosen comparison

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2026-05-10 15:32:34 +10:00
committed by GitHub
parent 29027c1cf2
commit bb2a72a6fb
16 changed files with 335 additions and 51 deletions
+4 -1
View File
@@ -38,7 +38,10 @@ export function bomItemFields({
},
addCreateFields: newPartFields
},
quantity: {},
raw_amount: {
label: t`Quantity`,
description: t`Required component quantity`
},
reference: {},
setup_quantity: {},
attrition: {},
+2 -2
View File
@@ -1,4 +1,4 @@
Assembly,Component,Reference,Quantity,Allow Variants,Gets inherited,Optional,Consumable,Setup quantity,Attrition,Rounding multiple,Note,ID,Pricing min,Pricing max,Pricing min total,Pricing max total,Pricing updated,Component.Ipn,Component.Name,Component.Description,Validated,Available Stock,Available substitute stock,Available variant stock,External stock,On Order,In Production,Can Build
Assembly,Component,Reference,Amount,Allow Variants,Gets inherited,Optional,Consumable,Setup quantity,Attrition,Rounding multiple,Note,ID,Pricing min,Pricing max,Pricing min total,Pricing max total,Pricing updated,Component.Ipn,Component.Name,Component.Description,Validated,Available Stock,Available substitute stock,Available variant stock,External stock,On Order,In Production,Can Build
106,98,screws,5,FALSE,TRUE,FALSE,TRUE,0,0,0,,39,0.075,0.1,0.375,0.5,23/07/2025 9:12,,Wood Screw,Screw for fixing wood to other wood,TRUE,1604,0,0,0,0,0,320.8
106,95,legs,4,FALSE,TRUE,FALSE,FALSE,0,0,0,,40,10.6,12.75,42.4,51,23/07/2025 9:12,,Leg,Leg for a chair or a table,TRUE,317,0,0,0,0,0,79.25
109,92,paint,0.125,FALSE,FALSE,FALSE,FALSE,0,0,0,,43,1.403886,14.389836,0.175486,1.79873,23/07/2025 9:12,,Green Paint,Green Paint,TRUE,110.125,0,0,0,0,0,881
109,92,paint,quart,FALSE,FALSE,FALSE,FALSE,0,0,0,,43,1.403886,14.389836,0.175486,1.79873,23/07/2025 9:12,,Green Paint,Green Paint,TRUE,110.125,0,0,0,0,0,881
1 Assembly Component Reference Quantity Amount Allow Variants Gets inherited Optional Consumable Setup quantity Attrition Rounding multiple Note ID Pricing min Pricing max Pricing min total Pricing max total Pricing updated Component.Ipn Component.Name Component.Description Validated Available Stock Available substitute stock Available variant stock External stock On Order In Production Can Build
2 106 98 screws 5 5 FALSE TRUE FALSE TRUE 0 0 0 39 0.075 0.1 0.375 0.5 23/07/2025 9:12 Wood Screw Screw for fixing wood to other wood TRUE 1604 0 0 0 0 0 320.8
3 106 95 legs 4 4 FALSE TRUE FALSE FALSE 0 0 0 40 10.6 12.75 42.4 51 23/07/2025 9:12 Leg Leg for a chair or a table TRUE 317 0 0 0 0 0 79.25
4 109 92 paint 0.125 quart FALSE FALSE FALSE FALSE 0 0 0 43 1.403886 14.389836 0.175486 1.79873 23/07/2025 9:12 Green Paint Green Paint TRUE 110.125 0 0 0 0 0 881
+53 -3
View File
@@ -1,3 +1,4 @@
import { expect } from '@playwright/test';
import { test } from '../baseFixtures';
import {
clearTableFilters,
@@ -219,6 +220,53 @@ test('Parts - BOM', async ({ browser }) => {
await page.getByRole('button', { name: 'Add Substitute' }).waitFor();
await page.getByRole('button', { name: 'Close' }).click();
// Let's try a BOM which has a "raw amount" which considers the units of the underlying part
await navigate(page, 'part/109/bom');
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
const paintCell = await page.getByRole('cell', {
name: 'Thumbnail Green Paint'
});
await clickOnRowMenu(paintCell);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
await expect(
page.getByRole('textbox', { name: 'text-field-raw_amount' })
).toHaveValue(/quart/);
// Try to assign invalid units to this item, which should be rejected by validation
await page
.getByRole('textbox', { name: 'text-field-raw_amount' })
.fill('2 cm');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Errors exist for one or more').waitFor();
await page.getByText('Could not convert 2 cm to litres').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
// Create a new BOM item with valid units
await page.getByRole('button', { name: 'action-menu-add-bom-items' }).click();
await page
.getByRole('menuitem', { name: 'action-menu-add-bom-items-add' })
.click();
await page
.getByRole('combobox', { name: 'related-field-sub_part' })
.fill('red wire');
await page
.getByRole('option', { name: 'Thumbnail Silicon Wire 12AWG' })
.click();
await page
.getByRole('textbox', { name: 'text-field-reference' })
.fill('my-ref');
await page
.getByRole('textbox', { name: 'text-field-raw_amount' })
.fill('3/4 inches');
await page.getByRole('switch', { name: 'boolean-field-optional' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// Check for the value converted back to [m]
await page.getByRole('cell', { name: '0.01905' }).first().waitFor();
await page.getByRole('cell', { name: 'my-ref' }).first().waitFor();
// Finish editing the BOM
await page
.getByRole('button', { name: 'action-button-finish-editing-' })
@@ -240,13 +288,15 @@ test('Parts - BOM Validation', async ({ browser }) => {
.waitFor();
// Edit line item, to ensure BOM is not valid
const cell = await page.getByRole('cell', { name: 'Thumbnail Red Paint' });
const cell = await page.getByRole('cell', { name: 'paint', exact: true });
// await cell.click({ button: 'right' });
// await page.getByRole('button', { name: 'Edit', exact: true }).click();
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
const input = await page.getByRole('textbox', {
name: 'number-field-quantity'
name: 'text-field-raw_amount'
});
const value = await input.inputValue();
@@ -901,7 +951,7 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
test('Parts - Test Results', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: '/part/74/test_results' });
await page.waitForTimeout(500);
await page.waitForTimeout(200);
await page.getByText(/1 - \d+ \/ 1\d\d/).waitFor();
await page.getByText('Blue Paint Applied').waitFor();
+1 -1
View File
@@ -138,7 +138,7 @@ test('Importing - BOM', async ({ browser }) => {
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('12');
await page.getByRole('textbox', { name: 'text-field-raw_amount' }).fill('12');
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
+1 -1
View File
@@ -401,7 +401,7 @@ test('Settings - Admin - Parameter', async ({ browser }) => {
await loadTab(page, 'Parameters', true);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
await page.waitForTimeout(500);
// Clean old template data if exists
await page