2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-22 01:06:50 +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,11 +1,14 @@
"""InvenTree API version information."""
# 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."""
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
- Adds more docstrings to (scheduled) tasks
@@ -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:
@@ -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
@@ -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:
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 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
"""
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 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
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 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
"""
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
+31 -22
View File
@@ -22,9 +22,9 @@ from sql_util.utils import SubqueryCount
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 BomItemSerializer(
'part',
'sub_part',
'reference',
'raw_amount',
'quantity',
'allow_variants',
'inherited',
@@ -1694,7 +1695,13 @@ class BomItemSerializer(
'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)
@@ -1704,25 +1711,6 @@ class BomItemSerializer(
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 +1852,27 @@ class BomItemSerializer(
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
def annotate_queryset(queryset):
"""Annotate the BomItem queryset with extra information.
+63 -8
View File
@@ -1005,7 +1005,9 @@ class PartAPITest(PartAPITestBase):
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 @@ class PartAPITest(PartAPITestBase):
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 @@ class PartAPITest(PartAPITestBase):
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 @@ class BomItemTest(InvenTreeAPITestCase):
'rounding_multiple',
'pk',
'part',
'raw_amount',
'quantity',
'reference',
'sub_part',
@@ -2821,6 +2824,7 @@ class BomItemTest(InvenTreeAPITestCase):
# Increase the quantity
data = response.data
del data['raw_amount']
data['quantity'] = 57
data['note'] = 'Added a note'
@@ -2829,6 +2833,14 @@ class BomItemTest(InvenTreeAPITestCase):
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 @@ class BomItemTest(InvenTreeAPITestCase):
"""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)
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
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."""
+1 -1
View File
@@ -438,7 +438,7 @@ class BomItemTest(TestCase):
# 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)
@@ -49,7 +49,7 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
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 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
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