From 434a2c7e68143b42f1d7b084547321a35c3cbf14 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 30 Mar 2026 10:04:09 +0000 Subject: [PATCH 01/22] Add new "raw_amount" field to BomItem model --- .../migrations/0148_bomitem_raw_amount.py | 54 +++++++++++++++++++ src/backend/InvenTree/part/models.py | 12 ++++- 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py diff --git a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py new file mode 100644 index 000000000000..4ef1b7ac4adb --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.12 on 2026-03-30 10:01 + +from django.db import migrations, models + + +def set_default_raw_amount(apps, schema_editor): + """Initialize the 'raw_amount' field for existing BomItem records.""" + BomItem = apps.get_model("part", "BomItem") + + to_update = [] + + for item in BomItem.objects.all(): + item.raw_amount = str(item.quantity) + to_update.append(item) + + if len(to_update) > 0: + print(f"Initializing 'raw_amount' field for {len(to_update)} BomItem records.") + BomItem.objects.bulk_update(to_update, ["raw_amount"]) + + +class Migration(migrations.Migration): + """Run a set of data and schema migrations to add a new 'raw_amount' field to the BomItem model. + + 1. Add a new 'raw_amount' field to the BomItem model, which is a CharField that can store a raw amount of sub-part consumed to produce one part. This field can be used to store fractional amounts or amounts with associated units (e.g. '10 hours', '5 kg', etc.). + 2. Run a data migration to initialize the 'raw_amount' field for existing BomItem records, by copying the value from the existing 'quantity' field and converting it to a string. + 3. Mark the 'raw_amount' field as blank=False, to allow for existing records that may not have a valid raw amount (e.g. if the quantity is not a simple number). + """ + + dependencies = [ + ("part", "0147_remove_part_default_supplier"), + ] + + operations = [ + migrations.AddField( + model_name="bomitem", + name="raw_amount", + field=models.CharField( + blank=True, + help_text="Raw amount of sub-part consumed to produce one part", + max_length=25, + verbose_name="Raw Amount", + ), + ), + migrations.RunPython(set_default_raw_amount, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name="bomitem", + name="raw_amount", + field=models.CharField( + help_text="Raw amount of sub-part consumed to produce one part", + max_length=25, + verbose_name="Raw Amount", + ), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index e4e50f4675ea..29e7e6620b8d 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3779,7 +3779,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): Attributes: part: Link to the parent part (the part that will be produced) sub_part: Link to the child part (the part that will be consumed) - quantity: Number of 'sub_parts' consumed to produce one 'part' + raw_amount: Raw amount of 'sub_part' consumed to produce one 'part' (can be fractional, or use an associated unit) + quantity: Quantity of 'sub_parts' consumed to produce one 'part' optional: Boolean field describing if this BomItem is optional consumable: Boolean field describing if this BomItem is considered a 'consumable' reference: BOM reference field (e.g. part designators) @@ -3984,7 +3985,14 @@ def check_part_lock(self, assembly): limit_choices_to={'component': True}, ) - # Quantity required + raw_amount = models.CharField( + max_length=25, + verbose_name=_('Raw Amount'), + help_text=_('Raw amount of sub-part consumed to produce one part'), + blank=False, + ) + + # Native quantity required quantity = models.DecimalField( default=1.0, max_digits=15, From 540e605be2120925f219bc26b8c86e2c39686b66 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 30 Mar 2026 10:10:08 +0000 Subject: [PATCH 02/22] Batch process data migration --- .../migrations/0148_bomitem_raw_amount.py | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py index 4ef1b7ac4adb..a85ab0bb1cab 100644 --- a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py +++ b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py @@ -8,14 +8,37 @@ def set_default_raw_amount(apps, schema_editor): BomItem = apps.get_model("part", "BomItem") to_update = [] + updated_count = 0 + + # Run BulkUpdate in batches to avoid memory issues with large datasets + BATCH_SIZE = 100 + + N_BOM_ITEMS = BomItem.objects.count() + + def add_bom_item(item): + nonlocal to_update + nonlocal updated_count + + to_update.append(item) + + if len(to_update) >= BATCH_SIZE: + BomItem.objects.bulk_update(to_update, ["raw_amount"]) + updated_count += len(to_update) + to_update = [] for item in BomItem.objects.all(): item.raw_amount = str(item.quantity) - to_update.append(item) + add_bom_item(item) + # Handle any remaining items that were not updated in the loop if len(to_update) > 0: - print(f"Initializing 'raw_amount' field for {len(to_update)} BomItem records.") BomItem.objects.bulk_update(to_update, ["raw_amount"]) + updated_count += len(to_update) + + assert updated_count == N_BOM_ITEMS, f"Expected to update {N_BOM_ITEMS} BomItem records, but updated {updated_count} records instead." + + if updated_count > 0: + print(f"Initialized 'raw_amount' field for {updated_count} BomItem records.") class Migration(migrations.Migration): From 0384b0f05d05294630c2bf48844c10ca34ee4eec Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 07:35:51 +0000 Subject: [PATCH 03/22] Update migration --- .../InvenTree/part/migrations/0148_bomitem_raw_amount.py | 5 +++++ src/backend/InvenTree/part/models.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py index a85ab0bb1cab..0d71b0c31652 100644 --- a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py +++ b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py @@ -37,6 +37,8 @@ def add_bom_item(item): assert updated_count == N_BOM_ITEMS, f"Expected to update {N_BOM_ITEMS} BomItem records, but updated {updated_count} records instead." + assert BomItem.objects.filter(raw_amount="").count() == 0, "There are BomItem records with an empty 'raw_amount' field after migration." + if updated_count > 0: print(f"Initialized 'raw_amount' field for {updated_count} BomItem records.") @@ -59,6 +61,7 @@ class Migration(migrations.Migration): name="raw_amount", field=models.CharField( blank=True, + null=False, help_text="Raw amount of sub-part consumed to produce one part", max_length=25, verbose_name="Raw Amount", @@ -69,6 +72,8 @@ class Migration(migrations.Migration): model_name="bomitem", name="raw_amount", field=models.CharField( + blank=False, + null=False, help_text="Raw amount of sub-part consumed to produce one part", max_length=25, verbose_name="Raw Amount", diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 29e7e6620b8d..1abcf5de0f8a 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3780,7 +3780,7 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): part: Link to the parent part (the part that will be produced) sub_part: Link to the child part (the part that will be consumed) raw_amount: Raw amount of 'sub_part' consumed to produce one 'part' (can be fractional, or use an associated unit) - quantity: Quantity of 'sub_parts' consumed to produce one 'part' + quantity: Numerical quantity of 'sub_parts' consumed to produce one 'part' optional: Boolean field describing if this BomItem is optional consumable: Boolean field describing if this BomItem is considered a 'consumable' reference: BOM reference field (e.g. part designators) @@ -3990,6 +3990,7 @@ def check_part_lock(self, assembly): verbose_name=_('Raw Amount'), help_text=_('Raw amount of sub-part consumed to produce one part'), blank=False, + null=False, ) # Native quantity required From 738895b16d8b6aac3043e44ace1b0b10731e144a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 08:55:06 +0000 Subject: [PATCH 04/22] Calculate 'quantity' from 'raw_amount' field --- src/backend/InvenTree/part/models.py | 51 +++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 8d79cf864912..4112793b1a76 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3876,6 +3876,51 @@ def get_stock_filter(self): """ return Q(part__in=self.get_valid_parts_for_allocation()) + def recalculate_quantity(self): + """Recalculate the 'quantity' field based on the 'raw_amount' field.""" + if self.raw_amount in [None, '']: + self.raw_amount = self.quantity + + # Convert from the "raw amount" to a numerical quantity, using the associated unit (if specified) + try: + quantity = InvenTree.conversion.convert_physical_value( + self.raw_amount, self.sub_part.units, strip_units=False + ) + + if not self.sub_part.units and not InvenTree.conversion.is_dimensionless( + quantity + ): + raise ValidationError({ + 'raw_amount': _('Invalid quantity - no units specified for part') + }) + + allow_zero_qty = get_global_setting('PART_BOM_ALLOW_ZERO_QUANTITY', False) + + if allow_zero_qty: + if float(quantity.magnitude) < 0: + raise ValidationError({ + 'raw_amount': _( + 'Quantity must be greater than or equal to zero' + ) + }) + + else: + if float(quantity.magnitude) <= 0: + raise ValidationError({ + 'raw_amount': _('Quantity must be greater than zero') + }) + + self.quantity = Decimal(quantity.magnitude) + + except ValidationError as e: + raise ValidationError({'raw_amount': e.messages}) + + # Ensure that the raw_amount is converted to a Decimal value + try: + self.quantity = Decimal(self.quantity) + except InvalidOperation: + raise ValidationError({'quantity': _('Must be a valid number')}) + def delete(self): """Check if this item can be deleted.""" import part.tasks as part_tasks @@ -4171,10 +4216,8 @@ def clean(self): """ super().clean() - try: - self.quantity = Decimal(self.quantity) - except InvalidOperation: - raise ValidationError({'quantity': _('Must be a valid number')}) + # Recalculate the 'quantity' field based on the 'raw_amount' field + self.recalculate_quantity() try: # Check for circular BOM references From 47883871d18f79d859120e6c43a5587287fe3f6f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 08:58:57 +0000 Subject: [PATCH 05/22] Improve decimal formatting in migration --- .../InvenTree/part/migrations/0148_bomitem_raw_amount.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py index 0d71b0c31652..c0b9f787075b 100644 --- a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py +++ b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py @@ -1,7 +1,11 @@ # Generated by Django 5.2.12 on 2026-03-30 10:01 +from decimal import Decimal + from django.db import migrations, models +from InvenTree.helpers import normalize + def set_default_raw_amount(apps, schema_editor): """Initialize the 'raw_amount' field for existing BomItem records.""" @@ -27,7 +31,7 @@ def add_bom_item(item): to_update = [] for item in BomItem.objects.all(): - item.raw_amount = str(item.quantity) + item.raw_amount = str(normalize(Decimal(item.quantity))) add_bom_item(item) # Handle any remaining items that were not updated in the loop From 4f21a976b2568100c3fb1bdca0a1ea901523dc52 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 08:59:16 +0000 Subject: [PATCH 06/22] Allow raw_amount in serializer --- src/backend/InvenTree/part/serializers.py | 36 ++++++++++------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 8788ca8624ab..d70061a222c1 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -22,7 +22,6 @@ import common.currency import common.filters -import common.models import common.serializers import company.models import InvenTree.helpers @@ -1628,6 +1627,7 @@ class Meta: 'part', 'sub_part', 'reference', + 'raw_amount', 'quantity', 'allow_variants', 'inherited', @@ -1662,7 +1662,7 @@ class Meta: 'category_detail', ] - quantity = InvenTree.serializers.InvenTreeDecimalField(required=True) + quantity = InvenTree.serializers.InvenTreeDecimalField(required=False) setup_quantity = InvenTree.serializers.InvenTreeDecimalField(required=False) @@ -1672,25 +1672,6 @@ class Meta: required=False, allow_null=True ) - def validate_quantity(self, quantity): - """Perform validation for the BomItem quantity field.""" - allow_zero_qty = common.models.InvenTreeSetting.get_setting( - 'PART_BOM_ALLOW_ZERO_QUANTITY', False - ) - - if allow_zero_qty: - if quantity < 0: - raise serializers.ValidationError( - _('Quantity must be greater than or equal to zero') - ) - else: - if quantity <= 0: - raise serializers.ValidationError( - _('Quantity must be greater than zero') - ) - - return quantity - part = serializers.PrimaryKeyRelatedField( queryset=Part.objects.filter(assembly=True), label=_('Assembly'), @@ -1803,6 +1784,19 @@ def validate_quantity(self, quantity): external_stock = serializers.FloatField(read_only=True, allow_null=True) + def validate(self, data): + """Validate the supplied data. + + Here, for legacy support, we intercept the 'quantity' field + (if the 'raw_amount' field is not provided) + """ + qty = data.pop('quantity', None) + + if 'raw_amount' not in data: + data['raw_amount'] = qty + + return super().validate(data) + @staticmethod def annotate_queryset(queryset): """Annotate the BomItem queryset with extra information. From 0e4cac4a73b648a8fa55d79487ca31cde6291da8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 09:01:46 +0000 Subject: [PATCH 07/22] Adjust frontend form --- src/frontend/src/forms/BomForms.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/forms/BomForms.tsx b/src/frontend/src/forms/BomForms.tsx index d512c66428d1..bef8f008fc15 100644 --- a/src/frontend/src/forms/BomForms.tsx +++ b/src/frontend/src/forms/BomForms.tsx @@ -32,7 +32,10 @@ export function bomItemFields({ component: true } }, - quantity: {}, + raw_amount: { + label: t`Quantity`, + description: t`Raw quantity of the required part` + }, reference: {}, setup_quantity: {}, attrition: {}, From 491c58589ce11865eedbd29bc9d5f83f94a89374 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 10:20:40 +0000 Subject: [PATCH 08/22] API validation and unit tests --- src/backend/InvenTree/InvenTree/conversion.py | 2 +- src/backend/InvenTree/part/models.py | 3 +- src/backend/InvenTree/part/serializers.py | 21 ++++++++ src/backend/InvenTree/part/test_api.py | 51 +++++++++++++++++-- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/conversion.py b/src/backend/InvenTree/InvenTree/conversion.py index aedf4168833a..f670d0c71c13 100644 --- a/src/backend/InvenTree/InvenTree/conversion.py +++ b/src/backend/InvenTree/InvenTree/conversion.py @@ -184,7 +184,7 @@ def from_engineering_notation(value): return value -def convert_value(value, unit): +def convert_value(value, unit=None): """Attempt to convert a value to a specified unit. Arguments: diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 4112793b1a76..c1c5c9aae3b5 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3919,7 +3919,8 @@ def recalculate_quantity(self): try: self.quantity = Decimal(self.quantity) except InvalidOperation: - raise ValidationError({'quantity': _('Must be a valid number')}) + msg = _('Invalid quantity provided') + raise ValidationError({'quantity': msg, 'raw_amount': msg}) def delete(self): """Check if this item can be deleted.""" diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index d70061a222c1..4d80a19a294e 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -24,6 +24,7 @@ import common.filters import common.serializers import company.models +import InvenTree.conversion import InvenTree.helpers import InvenTree.serializers import part.filters as part_filters @@ -1662,6 +1663,26 @@ class Meta: 'category_detail', ] + raw_amount = serializers.CharField( + label=_('Raw Amount'), + help_text=_('Raw amount required for this item (can include units)'), + required=False, + ) + + def validate_raw_amount(self, value): + """Validate the raw_amount field.""" + # Check for null values + if value is None or value.strip() == '': + raise ValidationError(_('Quantity cannot be empty')) + + try: + # Check that the value is acceptable to the unit registry + InvenTree.conversion.convert_value(value) + except Exception: + raise ValidationError(_('Invalid quantity format')) + + return value + quantity = InvenTree.serializers.InvenTreeDecimalField(required=False) setup_quantity = InvenTree.serializers.InvenTreeDecimalField(required=False) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 557d594f141b..1ce41188d517 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -2762,14 +2762,57 @@ def test_add_bom_item(self): """Test that we can create a new BomItem via the API.""" url = reverse('api-bom-list') + # Test with legacy format (only the 'quantity' field is supplied) data = {'part': 100, 'sub_part': 4, 'quantity': 777} + response = self.post(url, data, expected_code=201) + self.assertEqual(response.data['raw_amount'], '777') + self.assertEqual(response.data['quantity'], 777) + + # Test with the 'modern' format (accepts a raw_amount field) + data = {'part': 100, 'sub_part': 4, 'raw_amount': '123.45'} + response = self.post(url, data, expected_code=201) + self.assertEqual(response.data['raw_amount'], '123.45') + self.assertEqual(response.data['quantity'], 123.45) + + # First, let's assign some units to the sub_part + sub_part = Part.objects.get(pk=4) + sub_part.units = 'metres' + sub_part.save() + + # Test with a bunch of invalid 'raw_amount' values + for value in [ + '3 ampere', + '17 degrees', + '1 kg', + '-4', + 'yak', + '*****', + '$$$$$', + '', + ]: + data = {'part': 100, 'sub_part': 4, 'raw_amount': value} + self.post(url, data, expected_code=400) + + # Test with a bunch of valid 'raw_amount' values + test_values = [ + (5, 5), + ('3.14cm', 0.0314), + ('10 metres ', 10), + ('2 inches', 0.0508), + ('1/7', 0.142857), + ('14 ', 14), + ] - self.post(url, data, expected_code=201) + for raw_amount, quantity in test_values: + data = {'part': 100, 'sub_part': 4, 'raw_amount': raw_amount} + response = self.post(url, data, expected_code=201) + self.assertEqual(response.data['raw_amount'], str(raw_amount).strip()) + self.assertAlmostEqual(response.data['quantity'], quantity, places=4) # Now try to create a BomItem which references itself - data['part'] = 100 - data['sub_part'] = 100 - self.post(url, data, expected_code=400) + data = {'part': 100, 'sub_part': 100, 'quantity': 1} + response = self.post(url, data, expected_code=400) + self.assertIn('(recursive)', str(response.data)) def test_variants(self): """Tests for BomItem use with variants.""" From 77b84e5b20bd45fa14634483f7bda6555bf34077 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 23:40:54 +0000 Subject: [PATCH 09/22] Additional playwright tests --- .../tests/customization/customization.spec.ts | 18 +++---- src/frontend/tests/pages/pui_part.spec.ts | 47 ++++++++++++++++++- src/frontend/tests/pui_settings.spec.ts | 2 +- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/frontend/tests/customization/customization.spec.ts b/src/frontend/tests/customization/customization.spec.ts index a026868ea891..dbacc395f8e8 100644 --- a/src/frontend/tests/customization/customization.spec.ts +++ b/src/frontend/tests/customization/customization.spec.ts @@ -1,7 +1,5 @@ import test, { expect } from '@playwright/test'; -import { noaccessuser } from '../defaults'; import { navigate } from '../helpers'; -import { doLogin } from '../login'; /** * Tests for user interface customization functionality. @@ -21,13 +19,11 @@ test('Customization - Splash', async ({ page }) => { ).toBeVisible(); }); -test('Customization - Logo', async ({ page }) => { - await doLogin(page, { - user: noaccessuser - }); +// TODO: Implement this test +// test('Customization - Logo', async ({ page }) => { +// await doLogin(page, { +// user: noaccessuser +// }); - await page.waitForLoadState('networkidle'); - - await page.waitForTimeout(2500); - return; -}); +// await page.waitForLoadState('networkidle'); +// }); diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 840edcb1bf9a..67e4ebe171bd 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -1,3 +1,4 @@ +import { expect } from '@playwright/test'; import { test } from '../baseFixtures'; import { clearTableFilters, @@ -202,6 +203,50 @@ 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'); + + const paintCell = await page.getByRole('cell', { name: '0.94635' }); + await clickOnRowMenu(paintCell); + await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); + await expect( + page.getByRole('textbox', { name: 'text-field-raw_amount' }) + ).toHaveValue('1 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(); }); /** @@ -799,8 +844,6 @@ 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(2500); - await page.getByText(/1 - \d+ \/ 1\d\d/).waitFor(); await page.getByText('Blue Paint Applied').waitFor(); }); diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index ca9258a08753..a67c0d291877 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -403,7 +403,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 From 193a3897aada8cb2b535fc0cf5d25ed3f7ecee29 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 23:44:43 +0000 Subject: [PATCH 10/22] Update API version and CHANGELOG --- CHANGELOG.md | 2 ++ src/backend/InvenTree/InvenTree/api_version.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c25341c43f..f7b42dbc780c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#11631](https://github.com/inventree/InvenTree/pull/11631) adds "raw_amount" field to the BomItem model, allowing BOM quantities to account for the units of measure of the underlying part. + ### Changed ### Removed diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5719f16cb08e..0bccc821446c 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 477 +INVENTREE_API_VERSION = 478 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v478 -> 2026-04-12 : https://github.com/inventree/InvenTree/pull/11631 + - Adds "raw_amount" field to the BomItem API endpoint + v477 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11617 - Non-functional refactor, adaptations of descriptions From af7410e038bd1381c4702ffc451d3982913aa8a6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 23:49:08 +0000 Subject: [PATCH 11/22] Updated docs --- docs/docs/manufacturing/bom.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/docs/manufacturing/bom.md b/docs/docs/manufacturing/bom.md index 4114b5b9ea6f..4ae5052148c4 100644 --- a/docs/docs/manufacturing/bom.md +++ b/docs/docs/manufacturing/bom.md @@ -18,7 +18,8 @@ A BOM for a particular assembly is comprised of a number (zero or more) of BOM " | --- | --- | | Part | A reference to another *Part* object which is required to build this assembly | | Reference | Optional reference field to describe the BOM Line Item, e.g. part designator | -| Quantity | The quantity of *Part* required for the assembly | +| Raw Amount | The raw quantity of the part required for the assembly, which can be expressed in different units of measure, e.g. `2 cm`, `1/2 inch`, `200 kg`. | +| Quantity | The quantity of *Part* required for the assembly - this value is automatically calculated from the "raw amount" field, taking into account the units of measure associated with the underlying part. | | Attrition | Estimated attrition losses for a production run. Expressed as a percentage of the base quantity (e.g. 2%) | | Setup Quantity | An additional quantity of the part which is required to account for fixed setup losses during the production process. This is added to the base quantity of the BOM line item | | Rounding Multiple | A value which indicates that the required quantity should be rounded up to the nearest multiple of this value. | @@ -27,6 +28,18 @@ A BOM for a particular assembly is comprised of a number (zero or more) of BOM " | Optional | A boolean field which indicates if this BOM Line Item is "optional" | | Note | Optional note field for additional information +### Units of Measure + +The `raw_amount` field allows the user to specify the required quantity of a particular part in different [units of measure](../concepts/units.md). The units of measure are determined by the underlying part definition. For example, if the part is defined with a default unit of measure of "kg", the user can specify the required quantity in "g", "mg", "lb", etc. + +The `raw_amount` field is stored as a string, and the `quantity` field is automatically calculated from the `raw_amount` field, taking into account the units of measure associated with the underlying part. This allows for greater flexibility in specifying the required quantity of a particular part, while still maintaining accurate tracking of inventory and production requirements. + +If the underlying part does not have a defined unit of measure, the `raw_amount` field is not allowed to have any units of measure specified, and the `quantity` field is simply a numeric representation of the `raw_amount` field. + +### Fractional Representation + +The `raw_amount` field also allows for fractional representation of the required quantity. For example, if the required quantity is 0.5 kg, the user can specify this as `500 g`, `0.5 kg`, `1/2 kg`, etc. The `quantity` field will be automatically calculated as 0.5 kg, regardless of the specific representation used in the `raw_amount` field. + ### Consumable BOM Line Items If a BOM line item is marked as *consumable*, this means that while the part and quantity information is tracked in the BOM, this line item does not get allocated to a [Build Order](./build.md). This may be useful for certain items that the user does not wish to track through the build process, as they may be low value, in abundant stock, or otherwise complicated to track. From 8ce67ff9af66df2f582d5e716a621c45200fc315 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 23:54:42 +0000 Subject: [PATCH 12/22] Fix docstring --- .../InvenTree/part/migrations/0148_bomitem_raw_amount.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py index c0b9f787075b..5772e5183018 100644 --- a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py +++ b/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py @@ -52,7 +52,7 @@ class Migration(migrations.Migration): 1. Add a new 'raw_amount' field to the BomItem model, which is a CharField that can store a raw amount of sub-part consumed to produce one part. This field can be used to store fractional amounts or amounts with associated units (e.g. '10 hours', '5 kg', etc.). 2. Run a data migration to initialize the 'raw_amount' field for existing BomItem records, by copying the value from the existing 'quantity' field and converting it to a string. - 3. Mark the 'raw_amount' field as blank=False, to allow for existing records that may not have a valid raw amount (e.g. if the quantity is not a simple number). + 3. Mark the 'raw_amount' field as blank=False, now that the quantity values have been copied across """ dependencies = [ From c793293bd7fc49eeb2e36c60b566f36c9845fcc0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 Apr 2026 23:55:46 +0000 Subject: [PATCH 13/22] Better handling of empty values --- src/backend/InvenTree/part/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index c1c5c9aae3b5..d3a7f84855dc 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3878,7 +3878,7 @@ def get_stock_filter(self): def recalculate_quantity(self): """Recalculate the 'quantity' field based on the 'raw_amount' field.""" - if self.raw_amount in [None, '']: + if self.raw_amount is None or self.raw_amount.strip() == '': self.raw_amount = self.quantity # Convert from the "raw amount" to a numerical quantity, using the associated unit (if specified) From eab85e9694856e735c644a148bff6dcdcbcab838 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 Apr 2026 01:47:38 +0000 Subject: [PATCH 14/22] Tweak unit tests --- src/backend/InvenTree/part/fixtures/bom.yaml | 6 ++++++ src/backend/InvenTree/part/models.py | 2 +- src/backend/InvenTree/part/test_api.py | 10 ++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/part/fixtures/bom.yaml b/src/backend/InvenTree/part/fixtures/bom.yaml index e3763eb41f70..09075934779d 100644 --- a/src/backend/InvenTree/part/fixtures/bom.yaml +++ b/src/backend/InvenTree/part/fixtures/bom.yaml @@ -7,6 +7,7 @@ part: 100 sub_part: 1 quantity: 10 + raw_amount: '10' allow_variants: True # 40 x R_2K2_0805 @@ -16,6 +17,7 @@ part: 100 sub_part: 3 quantity: 40 + raw_amount: '40' # 25 x C_22N_0805 - model: part.bomitem @@ -24,6 +26,7 @@ part: 100 sub_part: 5 quantity: 25 + raw_amount: '25' reference: ABCDE # 3 x Orphan @@ -33,6 +36,7 @@ part: 100 sub_part: 50 quantity: 3 + raw_amount: '3' reference: VWXYZ - model: part.bomitem @@ -41,6 +45,7 @@ part: 1 sub_part: 5 quantity: 3 + raw_amount: '3' reference: LMNOP # Make "Assembly" from "Bob" @@ -50,6 +55,7 @@ part: 101 sub_part: 100 quantity: 10 + raw_amount: '10' - model: part.bomitemsubstitute pk: 1 diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index d3a7f84855dc..3d8c6a515a00 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3878,7 +3878,7 @@ def get_stock_filter(self): def recalculate_quantity(self): """Recalculate the 'quantity' field based on the 'raw_amount' field.""" - if self.raw_amount is None or self.raw_amount.strip() == '': + if self.raw_amount is None or self.raw_amount == '': self.raw_amount = self.quantity # Convert from the "raw amount" to a numerical quantity, using the associated unit (if specified) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 925b82e1d159..bd799d770ab7 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -2788,6 +2788,7 @@ def test_get_bom_detail(self): 'rounding_multiple', 'pk', 'part', + 'raw_amount', 'quantity', 'reference', 'sub_part', @@ -2813,6 +2814,7 @@ def test_get_bom_detail(self): # Increase the quantity data = response.data + del data['raw_amount'] data['quantity'] = 57 data['note'] = 'Added a note' @@ -2821,6 +2823,14 @@ def test_get_bom_detail(self): self.assertEqual(int(float(response.data['quantity'])), 57) self.assertEqual(response.data['note'], 'Added a note') + # Provide a conflicting "raw_amount" and "quantity" field + data['raw_amount'] = ' 123.45 ' + data['quantity'] = 99.99 + response = self.patch(url, data, expected_code=200) + + self.assertEqual(response.data['raw_amount'], '123.45') + self.assertAlmostEqual(response.data['quantity'], 123.45, places=2) + def test_output_options(self): """Test that various output options work as expected.""" self.run_output_test( From 5d5eea024bdfc94e9bfe4252302489765d21d2bc Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 Apr 2026 02:44:07 +0000 Subject: [PATCH 15/22] Tweak unit test --- src/backend/InvenTree/part/models.py | 5 +++++ .../plugin/samples/integration/test_validation_sample.py | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 3d8c6a515a00..b1c7f0837208 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3876,6 +3876,11 @@ def get_stock_filter(self): """ return Q(part__in=self.get_valid_parts_for_allocation()) + def set_quantity(self, quantity: Decimal | str | float): + """Update the 'quantity' for this BomItem.""" + self.raw_amount = quantity + self.recalculate_quantity() + def recalculate_quantity(self): """Recalculate the 'quantity' field based on the 'raw_amount' field.""" if self.raw_amount is None or self.raw_amount == '': diff --git a/src/backend/InvenTree/plugin/samples/integration/test_validation_sample.py b/src/backend/InvenTree/plugin/samples/integration/test_validation_sample.py index b0883344db45..f6f7f0fbb189 100644 --- a/src/backend/InvenTree/plugin/samples/integration/test_validation_sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/test_validation_sample.py @@ -49,7 +49,7 @@ def test_validate_model_instance(self): self.assembly.refresh_from_db() self.bom_item = part.models.BomItem.objects.create( - part=self.assembly, sub_part=self.part, quantity=1 + part=self.assembly, sub_part=self.part, raw_amount=1, quantity=1 ) self.enable_plugin(False) @@ -75,14 +75,14 @@ def test_validate_model_instance(self): plg.set_setting('BOM_ITEM_INTEGER', True) - self.bom_item.quantity = 3.14159 + self.bom_item.raw_amount = 3.14159 with self.assertRaises(ValidationError): self.bom_item.save() # Now, disable the plugin setting plg.set_setting('BOM_ITEM_INTEGER', False) - self.bom_item.quantity = 3.14159 + self.bom_item.set_quantity(3.14159) self.bom_item.save() # Test that we *cannot* set a part description to a shorter value From f5483b1307d04ae5b5bca34603991694ac51edf2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 Apr 2026 02:48:39 +0000 Subject: [PATCH 16/22] Fix unit test --- src/backend/InvenTree/part/test_api.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index bd799d770ab7..174f6633f666 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -1005,7 +1005,9 @@ def test_filter_by_bom_valid(self): sub_part.refresh_from_db() # Link the sub part to the assembly via a BOM - bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=10) + bom_item = BomItem.objects.create( + part=assembly, sub_part=sub_part, raw_amount='10', quantity=10 + ) filters = {'active': True, 'assembly': True, 'bom_valid': True} @@ -1023,14 +1025,14 @@ def test_filter_by_bom_valid(self): self.assertEqual(response.data[0]['pk'], assembly.pk) # Adjust the 'quantity' of the BOM item to make it invalid - bom_item.quantity = 15 + bom_item.set_quantity(15) bom_item.save() response = self.get(url, filters) self.assertEqual(len(response.data), 0) # Adjust it back again - should be valid again - bom_item.quantity = 10 + bom_item.set_quantity(10) bom_item.save() response = self.get(url, filters) @@ -1046,7 +1048,7 @@ def test_filter_by_bom_valid(self): self.assertIsNotNone(data['bom_checked_date']) # Now, let's try to validate and invalidate the assembly BOM via the API - bom_item.quantity = 99 + bom_item.raw_amount = ' 99' bom_item.save() data = self.get(bom_url, expected_code=200).data From 14cff41948411ce7ab0086d7d1789b57fa55be7c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 25 Apr 2026 23:54:20 +0000 Subject: [PATCH 17/22] Adjust form field text --- src/frontend/src/forms/BomForms.tsx | 2 +- src/frontend/tests/customization/customization.spec.ts | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/frontend/src/forms/BomForms.tsx b/src/frontend/src/forms/BomForms.tsx index 19b0be4b5bb4..dc92103edda7 100644 --- a/src/frontend/src/forms/BomForms.tsx +++ b/src/frontend/src/forms/BomForms.tsx @@ -40,7 +40,7 @@ export function bomItemFields({ }, raw_amount: { label: t`Quantity`, - description: t`Raw quantity of the required part` + description: t`Required component quantity` }, reference: {}, setup_quantity: {}, diff --git a/src/frontend/tests/customization/customization.spec.ts b/src/frontend/tests/customization/customization.spec.ts index dbacc395f8e8..9b792131ba9e 100644 --- a/src/frontend/tests/customization/customization.spec.ts +++ b/src/frontend/tests/customization/customization.spec.ts @@ -18,12 +18,3 @@ test('Customization - Splash', async ({ page }) => { page.locator('[style*="playwright_custom_splash.png"]') ).toBeVisible(); }); - -// TODO: Implement this test -// test('Customization - Logo', async ({ page }) => { -// await doLogin(page, { -// user: noaccessuser -// }); - -// await page.waitForLoadState('networkidle'); -// }); From fafec868d0d9c96a40a4aa39e7407979f1a46b17 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 3 May 2026 08:26:45 +0000 Subject: [PATCH 18/22] Adjust migration file --- .../{0148_bomitem_raw_amount.py => 0149_bomitem_raw_amount.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/backend/InvenTree/part/migrations/{0148_bomitem_raw_amount.py => 0149_bomitem_raw_amount.py} (98%) diff --git a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py b/src/backend/InvenTree/part/migrations/0149_bomitem_raw_amount.py similarity index 98% rename from src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py rename to src/backend/InvenTree/part/migrations/0149_bomitem_raw_amount.py index 5772e5183018..c39cd2c8081e 100644 --- a/src/backend/InvenTree/part/migrations/0148_bomitem_raw_amount.py +++ b/src/backend/InvenTree/part/migrations/0149_bomitem_raw_amount.py @@ -56,7 +56,7 @@ class Migration(migrations.Migration): """ dependencies = [ - ("part", "0147_remove_part_default_supplier"), + ("part", "0148_auto_20260427_2233"), ] operations = [ From 2d3b62f015043460d3c71f745b10c8cc34251951 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 3 May 2026 08:52:32 +0000 Subject: [PATCH 19/22] Tweak playwright tests --- src/frontend/tests/pages/pui_part.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 291ca1b345a9..1defa0982546 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -217,7 +217,11 @@ test('Parts - BOM', async ({ browser }) => { // Let's try a BOM which has a "raw amount" which considers the units of the underlying part await navigate(page, 'part/109/bom'); - const paintCell = await page.getByRole('cell', { name: '0.94635' }); + 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( @@ -278,13 +282,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(); From 60841dd59ad50b24fc32cc163df741097cb5eb63 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 3 May 2026 08:57:38 +0000 Subject: [PATCH 20/22] Fix unit test --- src/backend/InvenTree/part/test_bom_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/part/test_bom_item.py b/src/backend/InvenTree/part/test_bom_item.py index d2e8d91b917e..1e9a686a7735 100644 --- a/src/backend/InvenTree/part/test_bom_item.py +++ b/src/backend/InvenTree/part/test_bom_item.py @@ -438,7 +438,7 @@ def validate(valid: bool = True): # Editing the BOM item should also invalidate the bom_validated cache validate() - bom_item.quantity = 2 + bom_item.set_quantity(2) bom_item.save() check(valid=False) From 96e3325d26e8d3cebdbc4c1304be139a8ea13afb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 11:43:36 +0000 Subject: [PATCH 21/22] Adjust serializers / import-export / playwright --- src/backend/InvenTree/part/models.py | 4 ++-- src/backend/InvenTree/part/serializers.py | 6 +++--- src/frontend/tests/fixtures/bom_data.csv | 4 ++-- src/frontend/tests/pui_importing.spec.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 48d11e4d3e2e..dcbbde25dcc4 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -4044,8 +4044,8 @@ def check_part_lock(self, assembly): raw_amount = models.CharField( max_length=25, - verbose_name=_('Raw Amount'), - help_text=_('Raw amount of sub-part consumed to produce one part'), + verbose_name=_('Amount'), + help_text=_('Amount of sub-part consumed to produce one part'), blank=False, null=False, ) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index df2682afc55b..e1858df5a4f0 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1642,7 +1642,7 @@ class BomItemSerializer( ): """Serializer for BomItem object.""" - import_exclude_fields = ['validated', 'substitutes'] + import_exclude_fields = ['quantity', 'validated', 'substitutes'] export_exclude_fields = ['substitutes'] @@ -1696,8 +1696,8 @@ class Meta: ] raw_amount = serializers.CharField( - label=_('Raw Amount'), - help_text=_('Raw amount required for this item (can include units)'), + label=_('Amount'), + help_text=_('Amount required for this item (can include units)'), required=False, ) diff --git a/src/frontend/tests/fixtures/bom_data.csv b/src/frontend/tests/fixtures/bom_data.csv index 9522d142e0b4..81595405b715 100644 --- a/src/frontend/tests/fixtures/bom_data.csv +++ b/src/frontend/tests/fixtures/bom_data.csv @@ -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 diff --git a/src/frontend/tests/pui_importing.spec.ts b/src/frontend/tests/pui_importing.spec.ts index 3400f3564c11..0d1233eadc72 100644 --- a/src/frontend/tests/pui_importing.spec.ts +++ b/src/frontend/tests/pui_importing.spec.ts @@ -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(); From 660b1a43fd7e50be8456edbea0b286d731d1c238 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 12:21:20 +0000 Subject: [PATCH 22/22] Fix migration --- .../InvenTree/part/migrations/0149_bomitem_raw_amount.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/part/migrations/0149_bomitem_raw_amount.py b/src/backend/InvenTree/part/migrations/0149_bomitem_raw_amount.py index c39cd2c8081e..0f61638ae5be 100644 --- a/src/backend/InvenTree/part/migrations/0149_bomitem_raw_amount.py +++ b/src/backend/InvenTree/part/migrations/0149_bomitem_raw_amount.py @@ -66,9 +66,9 @@ class Migration(migrations.Migration): field=models.CharField( blank=True, null=False, - help_text="Raw amount of sub-part consumed to produce one part", + help_text="Amount of sub-part consumed to produce one part", max_length=25, - verbose_name="Raw Amount", + verbose_name="Amount", ), ), migrations.RunPython(set_default_raw_amount, reverse_code=migrations.RunPython.noop), @@ -78,9 +78,9 @@ class Migration(migrations.Migration): field=models.CharField( blank=False, null=False, - help_text="Raw amount of sub-part consumed to produce one part", + help_text="Amount of sub-part consumed to produce one part", max_length=25, - verbose_name="Raw Amount", + verbose_name="Amount", ), ), ]