diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f69a31f534..086f59d15bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ 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. - [#11872](https://github.com/inventree/InvenTree/pull/11872) adds a global setting to allow or disallow the deletion of serialized stock items. - [#11861](https://github.com/inventree/InvenTree/pull/11861) adds support for bulk-replacing a component in multiple BOMs simultaneously - [#11853](https://github.com/inventree/InvenTree/pull/11853) adds BOM comparison functionality, allowing users to compare the BOM of one assembly with another assembly. diff --git a/docs/docs/manufacturing/bom.md b/docs/docs/manufacturing/bom.md index b21c02b84ab6..ebd29053b36f 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. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index a326effd1e73..8a9f275a248c 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 = 483 +INVENTREE_API_VERSION = 484 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v484 -> 2026-05-05 : https://github.com/inventree/InvenTree/pull/11631 + - Adds "raw_amount" field to the BomItem API endpoint + v483 -> 2026-05-04 : https://github.com/inventree/InvenTree/pull/11861 - Enable bulk-update operations on the BomItem API endpoint, allowing multiple BOM items to be updated in a single API call 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/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/migrations/0149_bomitem_raw_amount.py b/src/backend/InvenTree/part/migrations/0149_bomitem_raw_amount.py new file mode 100644 index 000000000000..0f61638ae5be --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0149_bomitem_raw_amount.py @@ -0,0 +1,86 @@ +# 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.""" + 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(normalize(Decimal(item.quantity))) + add_bom_item(item) + + # Handle any remaining items that were not updated in the loop + if len(to_update) > 0: + 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." + + 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.") + + +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, now that the quantity values have been copied across + """ + + dependencies = [ + ("part", "0148_auto_20260427_2233"), + ] + + operations = [ + migrations.AddField( + model_name="bomitem", + name="raw_amount", + field=models.CharField( + blank=True, + null=False, + help_text="Amount of sub-part consumed to produce one part", + max_length=25, + verbose_name="Amount", + ), + ), + migrations.RunPython(set_default_raw_amount, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name="bomitem", + name="raw_amount", + field=models.CharField( + blank=False, + null=False, + help_text="Amount of sub-part consumed to produce one part", + max_length=25, + verbose_name="Amount", + ), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index f9e94d464929..e66393b77790 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3782,7 +3782,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: 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) @@ -3884,6 +3885,57 @@ 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 == '': + 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: + msg = _('Invalid quantity provided') + raise ValidationError({'quantity': msg, 'raw_amount': msg}) + def delete(self): """Check if this item can be deleted.""" import part.tasks as part_tasks @@ -3990,7 +4042,15 @@ def check_part_lock(self, assembly): limit_choices_to={'component': True}, ) - # Quantity required + raw_amount = models.CharField( + max_length=25, + verbose_name=_('Amount'), + help_text=_('Amount of sub-part consumed to produce one part'), + blank=False, + null=False, + ) + + # Native quantity required quantity = models.DecimalField( default=1.0, max_digits=15, @@ -4176,10 +4236,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 diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index eb8746be1865..e1858df5a4f0 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -22,9 +22,9 @@ import common.currency import common.filters -import common.models import common.serializers import company.models +import InvenTree.conversion import InvenTree.helpers import InvenTree.serializers import part.filters as part_filters @@ -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'] @@ -1660,6 +1660,7 @@ class Meta: 'part', 'sub_part', 'reference', + 'raw_amount', 'quantity', 'allow_variants', 'inherited', @@ -1694,7 +1695,27 @@ class Meta: 'category_detail', ] - quantity = InvenTree.serializers.InvenTreeDecimalField(required=True) + raw_amount = serializers.CharField( + label=_('Amount'), + help_text=_('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) @@ -1704,25 +1725,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'), @@ -1864,6 +1866,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. diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 1ba1caad9013..9eb9c98f5dd9 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) @@ -1054,7 +1056,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 @@ -2796,6 +2798,7 @@ def test_get_bom_detail(self): 'rounding_multiple', 'pk', 'part', + 'raw_amount', 'quantity', 'reference', 'sub_part', @@ -2821,6 +2824,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' @@ -2829,6 +2833,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( @@ -2846,14 +2858,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.""" 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) 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 diff --git a/src/frontend/src/forms/BomForms.tsx b/src/frontend/src/forms/BomForms.tsx index f701c9e5179c..dc92103edda7 100644 --- a/src/frontend/src/forms/BomForms.tsx +++ b/src/frontend/src/forms/BomForms.tsx @@ -38,7 +38,10 @@ export function bomItemFields({ }, addCreateFields: newPartFields }, - quantity: {}, + raw_amount: { + label: t`Quantity`, + description: t`Required component quantity` + }, reference: {}, setup_quantity: {}, attrition: {}, 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/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 4fd3df805cd6..76aa800551a7 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, @@ -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('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(); // 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(); 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(); diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index d9aa1e24ed99..35f2936dbd2d 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -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