2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-12 12:38:42 +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
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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. - [#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 - [#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. - [#11853](https://github.com/inventree/InvenTree/pull/11853) adds BOM comparison functionality, allowing users to compare the BOM of one assembly with another assembly.
+14 -1
View File
@@ -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 | | 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 | | 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%) | | 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 | | 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. | | 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" | | Optional | A boolean field which indicates if this BOM Line Item is "optional" |
| Note | Optional note field for additional information | 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 ### 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. 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.
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 484 INVENTREE_API_VERSION = 485
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v485 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11631
- Adds "raw_amount" field to the BomItem API endpoint
v484 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11910 v484 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11910
- Adds more docstrings to (scheduled) tasks - Adds more docstrings to (scheduled) tasks
@@ -184,7 +184,7 @@ def from_engineering_notation(value):
return value return value
def convert_value(value, unit): def convert_value(value, unit=None):
"""Attempt to convert a value to a specified unit. """Attempt to convert a value to a specified unit.
Arguments: Arguments:
@@ -7,6 +7,7 @@
part: 100 part: 100
sub_part: 1 sub_part: 1
quantity: 10 quantity: 10
raw_amount: '10'
allow_variants: True allow_variants: True
# 40 x R_2K2_0805 # 40 x R_2K2_0805
@@ -16,6 +17,7 @@
part: 100 part: 100
sub_part: 3 sub_part: 3
quantity: 40 quantity: 40
raw_amount: '40'
# 25 x C_22N_0805 # 25 x C_22N_0805
- model: part.bomitem - model: part.bomitem
@@ -24,6 +26,7 @@
part: 100 part: 100
sub_part: 5 sub_part: 5
quantity: 25 quantity: 25
raw_amount: '25'
reference: ABCDE reference: ABCDE
# 3 x Orphan # 3 x Orphan
@@ -33,6 +36,7 @@
part: 100 part: 100
sub_part: 50 sub_part: 50
quantity: 3 quantity: 3
raw_amount: '3'
reference: VWXYZ reference: VWXYZ
- model: part.bomitem - model: part.bomitem
@@ -41,6 +45,7 @@
part: 1 part: 1
sub_part: 5 sub_part: 5
quantity: 3 quantity: 3
raw_amount: '3'
reference: LMNOP reference: LMNOP
# Make "Assembly" from "Bob" # Make "Assembly" from "Bob"
@@ -50,6 +55,7 @@
part: 101 part: 101
sub_part: 100 sub_part: 100
quantity: 10 quantity: 10
raw_amount: '10'
- model: part.bomitemsubstitute - model: part.bomitemsubstitute
pk: 1 pk: 1
@@ -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",
),
),
]
+64 -6
View File
@@ -3782,7 +3782,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
Attributes: Attributes:
part: Link to the parent part (the part that will be produced) 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) 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 optional: Boolean field describing if this BomItem is optional
consumable: Boolean field describing if this BomItem is considered a 'consumable' consumable: Boolean field describing if this BomItem is considered a 'consumable'
reference: BOM reference field (e.g. part designators) reference: BOM reference field (e.g. part designators)
@@ -3884,6 +3885,57 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
""" """
return Q(part__in=self.get_valid_parts_for_allocation()) 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): def delete(self):
"""Check if this item can be deleted.""" """Check if this item can be deleted."""
import part.tasks as part_tasks import part.tasks as part_tasks
@@ -3990,7 +4042,15 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
limit_choices_to={'component': True}, 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( quantity = models.DecimalField(
default=1.0, default=1.0,
max_digits=15, max_digits=15,
@@ -4176,10 +4236,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
""" """
super().clean() super().clean()
try: # Recalculate the 'quantity' field based on the 'raw_amount' field
self.quantity = Decimal(self.quantity) self.recalculate_quantity()
except InvalidOperation:
raise ValidationError({'quantity': _('Must be a valid number')})
try: try:
# Check for circular BOM references # Check for circular BOM references
+31 -22
View File
@@ -22,9 +22,9 @@ from sql_util.utils import SubqueryCount
import common.currency import common.currency
import common.filters import common.filters
import common.models
import common.serializers import common.serializers
import company.models import company.models
import InvenTree.conversion
import InvenTree.helpers import InvenTree.helpers
import InvenTree.serializers import InvenTree.serializers
import part.filters as part_filters import part.filters as part_filters
@@ -1642,7 +1642,7 @@ class BomItemSerializer(
): ):
"""Serializer for BomItem object.""" """Serializer for BomItem object."""
import_exclude_fields = ['validated', 'substitutes'] import_exclude_fields = ['quantity', 'validated', 'substitutes']
export_exclude_fields = ['substitutes'] export_exclude_fields = ['substitutes']
@@ -1660,6 +1660,7 @@ class BomItemSerializer(
'part', 'part',
'sub_part', 'sub_part',
'reference', 'reference',
'raw_amount',
'quantity', 'quantity',
'allow_variants', 'allow_variants',
'inherited', 'inherited',
@@ -1694,7 +1695,13 @@ class BomItemSerializer(
'category_detail', '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,
)
quantity = InvenTree.serializers.InvenTreeDecimalField(required=False)
setup_quantity = InvenTree.serializers.InvenTreeDecimalField(required=False) setup_quantity = InvenTree.serializers.InvenTreeDecimalField(required=False)
@@ -1704,25 +1711,6 @@ class BomItemSerializer(
required=False, allow_null=True 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( part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.filter(assembly=True), queryset=Part.objects.filter(assembly=True),
label=_('Assembly'), label=_('Assembly'),
@@ -1864,6 +1852,27 @@ class BomItemSerializer(
external_stock = serializers.FloatField(read_only=True, allow_null=True) 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 and qty is not None:
data['raw_amount'] = qty
# Check the raw_amount field is valid (this will raise a ValidationError if not)
if raw_amount := data.get('raw_amount', None):
try:
# Check that the value is acceptable to the unit registry
InvenTree.conversion.convert_value(raw_amount)
except Exception:
raise ValidationError({'raw_amount': _('Invalid quantity format')})
return super().validate(data)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annotate the BomItem queryset with extra information. """Annotate the BomItem queryset with extra information.
+63 -8
View File
@@ -1005,7 +1005,9 @@ class PartAPITest(PartAPITestBase):
sub_part.refresh_from_db() sub_part.refresh_from_db()
# Link the sub part to the assembly via a BOM # 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} filters = {'active': True, 'assembly': True, 'bom_valid': True}
@@ -1023,14 +1025,14 @@ class PartAPITest(PartAPITestBase):
self.assertEqual(response.data[0]['pk'], assembly.pk) self.assertEqual(response.data[0]['pk'], assembly.pk)
# Adjust the 'quantity' of the BOM item to make it invalid # Adjust the 'quantity' of the BOM item to make it invalid
bom_item.quantity = 15 bom_item.set_quantity(15)
bom_item.save() bom_item.save()
response = self.get(url, filters) response = self.get(url, filters)
self.assertEqual(len(response.data), 0) self.assertEqual(len(response.data), 0)
# Adjust it back again - should be valid again # Adjust it back again - should be valid again
bom_item.quantity = 10 bom_item.set_quantity(10)
bom_item.save() bom_item.save()
response = self.get(url, filters) response = self.get(url, filters)
@@ -1054,7 +1056,7 @@ class PartAPITest(PartAPITestBase):
self.assertIsNotNone(data['bom_checked_date']) self.assertIsNotNone(data['bom_checked_date'])
# Now, let's try to validate and invalidate the assembly BOM via the API # 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() bom_item.save()
data = self.get(bom_url, expected_code=200).data data = self.get(bom_url, expected_code=200).data
@@ -2796,6 +2798,7 @@ class BomItemTest(InvenTreeAPITestCase):
'rounding_multiple', 'rounding_multiple',
'pk', 'pk',
'part', 'part',
'raw_amount',
'quantity', 'quantity',
'reference', 'reference',
'sub_part', 'sub_part',
@@ -2821,6 +2824,7 @@ class BomItemTest(InvenTreeAPITestCase):
# Increase the quantity # Increase the quantity
data = response.data data = response.data
del data['raw_amount']
data['quantity'] = 57 data['quantity'] = 57
data['note'] = 'Added a note' data['note'] = 'Added a note'
@@ -2829,6 +2833,14 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(int(float(response.data['quantity'])), 57) self.assertEqual(int(float(response.data['quantity'])), 57)
self.assertEqual(response.data['note'], 'Added a note') 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): def test_output_options(self):
"""Test that various output options work as expected.""" """Test that various output options work as expected."""
self.run_output_test( self.run_output_test(
@@ -2846,14 +2858,57 @@ class BomItemTest(InvenTreeAPITestCase):
"""Test that we can create a new BomItem via the API.""" """Test that we can create a new BomItem via the API."""
url = reverse('api-bom-list') url = reverse('api-bom-list')
# Test with legacy format (only the 'quantity' field is supplied)
data = {'part': 100, 'sub_part': 4, 'quantity': 777} 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)
self.post(url, data, expected_code=201) # 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),
]
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 # Now try to create a BomItem which references itself
data['part'] = 100 data = {'part': 100, 'sub_part': 100, 'quantity': 1}
data['sub_part'] = 100 response = self.post(url, data, expected_code=400)
self.post(url, data, expected_code=400) self.assertIn('(recursive)', str(response.data))
def test_variants(self): def test_variants(self):
"""Tests for BomItem use with variants.""" """Tests for BomItem use with variants."""
+1 -1
View File
@@ -438,7 +438,7 @@ class BomItemTest(TestCase):
# Editing the BOM item should also invalidate the bom_validated cache # Editing the BOM item should also invalidate the bom_validated cache
validate() validate()
bom_item.quantity = 2 bom_item.set_quantity(2)
bom_item.save() bom_item.save()
check(valid=False) check(valid=False)
@@ -49,7 +49,7 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
self.assembly.refresh_from_db() self.assembly.refresh_from_db()
self.bom_item = part.models.BomItem.objects.create( 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) self.enable_plugin(False)
@@ -75,14 +75,14 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
plg.set_setting('BOM_ITEM_INTEGER', True) plg.set_setting('BOM_ITEM_INTEGER', True)
self.bom_item.quantity = 3.14159 self.bom_item.raw_amount = 3.14159
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
self.bom_item.save() self.bom_item.save()
# Now, disable the plugin setting # Now, disable the plugin setting
plg.set_setting('BOM_ITEM_INTEGER', False) plg.set_setting('BOM_ITEM_INTEGER', False)
self.bom_item.quantity = 3.14159 self.bom_item.set_quantity(3.14159)
self.bom_item.save() self.bom_item.save()
# Test that we *cannot* set a part description to a shorter value # Test that we *cannot* set a part description to a shorter value
+4 -1
View File
@@ -38,7 +38,10 @@ export function bomItemFields({
}, },
addCreateFields: newPartFields addCreateFields: newPartFields
}, },
quantity: {}, raw_amount: {
label: t`Quantity`,
description: t`Required component quantity`
},
reference: {}, reference: {},
setup_quantity: {}, setup_quantity: {},
attrition: {}, 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,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 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 { test } from '../baseFixtures';
import { import {
clearTableFilters, clearTableFilters,
@@ -219,6 +220,53 @@ test('Parts - BOM', async ({ browser }) => {
await page.getByRole('button', { name: 'Add Substitute' }).waitFor(); await page.getByRole('button', { name: 'Add Substitute' }).waitFor();
await page.getByRole('button', { name: 'Close' }).click(); 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 // Finish editing the BOM
await page await page
.getByRole('button', { name: 'action-button-finish-editing-' }) .getByRole('button', { name: 'action-button-finish-editing-' })
@@ -240,13 +288,15 @@ test('Parts - BOM Validation', async ({ browser }) => {
.waitFor(); .waitFor();
// Edit line item, to ensure BOM is not valid // 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 clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
const input = await page.getByRole('textbox', { const input = await page.getByRole('textbox', {
name: 'number-field-quantity' name: 'text-field-raw_amount'
}); });
const value = await input.inputValue(); const value = await input.inputValue();
@@ -901,7 +951,7 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
test('Parts - Test Results', async ({ browser }) => { test('Parts - Test Results', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: '/part/74/test_results' }); 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(/1 - \d+ \/ 1\d\d/).waitFor();
await page.getByText('Blue Paint Applied').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-') .getByLabel('row-action-menu-')
.click(); .click();
await page.getByRole('menuitem', { name: 'Edit' }).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.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click(); 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 loadTab(page, 'Parameters', true);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000); await page.waitForTimeout(500);
// Clean old template data if exists // Clean old template data if exists
await page await page