Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
434a2c7
Add new "raw_amount" field to BomItem model
SchrodingersGat Mar 30, 2026
540e605
Batch process data migration
SchrodingersGat Mar 30, 2026
0384b0f
Update migration
SchrodingersGat Apr 11, 2026
d31d390
Merge commit 'fffc55c764b9cc0e3b84f39faf3993f2ecac37f0' into physical…
SchrodingersGat Apr 11, 2026
738895b
Calculate 'quantity' from 'raw_amount' field
SchrodingersGat Apr 11, 2026
69930a3
Merge commit '366d4c398c9b8f4f6f95715b6b1beaeae20cc788' into physical…
SchrodingersGat Apr 11, 2026
4788387
Improve decimal formatting in migration
SchrodingersGat Apr 11, 2026
4f21a97
Allow raw_amount in serializer
SchrodingersGat Apr 11, 2026
0e4cac4
Adjust frontend form
SchrodingersGat Apr 11, 2026
68a581b
Merge branch 'master' into physical-bom-lines
SchrodingersGat Apr 11, 2026
491c585
API validation and unit tests
SchrodingersGat Apr 11, 2026
ea72c12
Merge branch 'physical-bom-lines' of github.com:SchrodingersGat/Inven…
SchrodingersGat Apr 11, 2026
dc92259
Merge commit '5aaf1cfcab03204b1b6fd4cc8d7e4737636d9477' into physical…
SchrodingersGat Apr 11, 2026
77b84e5
Additional playwright tests
SchrodingersGat Apr 11, 2026
193a389
Update API version and CHANGELOG
SchrodingersGat Apr 11, 2026
af7410e
Updated docs
SchrodingersGat Apr 11, 2026
8ce67ff
Fix docstring
SchrodingersGat Apr 11, 2026
c793293
Better handling of empty values
SchrodingersGat Apr 11, 2026
1fc719f
Merge master
SchrodingersGat Apr 12, 2026
eab85e9
Tweak unit tests
SchrodingersGat Apr 12, 2026
5d5eea0
Tweak unit test
SchrodingersGat Apr 12, 2026
f5483b1
Fix unit test
SchrodingersGat Apr 12, 2026
eb3eb52
Merge branch 'master' into physical-bom-lines
matmair Apr 19, 2026
b80fe89
Merge branch 'master' into physical-bom-lines
SchrodingersGat Apr 21, 2026
f1edc44
Merge branch 'master' into physical-bom-lines
matmair Apr 23, 2026
af5f116
Merge branch 'master' into physical-bom-lines
matmair Apr 23, 2026
5fe98a3
Merge branch 'master' into physical-bom-lines
SchrodingersGat Apr 25, 2026
ae1fc3c
Merge commit 'e684e002f75382bb61e3a49fc6569b9f06a6ce1d' into physical…
SchrodingersGat Apr 25, 2026
14cff41
Adjust form field text
SchrodingersGat Apr 25, 2026
024061b
Merge branch 'master' into physical-bom-lines
SchrodingersGat Apr 26, 2026
e09b7cd
Merge commit '24ce51c5ca335fc3c463a67299b7fda815f2e77b' into physical…
SchrodingersGat May 3, 2026
fafec86
Adjust migration file
SchrodingersGat May 3, 2026
2d3b62f
Tweak playwright tests
SchrodingersGat May 3, 2026
60841dd
Fix unit test
SchrodingersGat May 3, 2026
7798431
Merge branch 'master' into physical-bom-lines
SchrodingersGat May 3, 2026
96e3325
Adjust serializers / import-export / playwright
SchrodingersGat May 4, 2026
6e4b3f6
Merge commit '118bc63b6b4b1866beeffb87a0fb54238d40d197' into physical…
SchrodingersGat May 4, 2026
660b1a4
Fix migration
SchrodingersGat May 4, 2026
7091076
Merge commit '6f1d40d546422bda16264392e68ce607a13215f2' into physical…
SchrodingersGat May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion docs/docs/manufacturing/bom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/backend/InvenTree/InvenTree/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions src/backend/InvenTree/part/fixtures/bom.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
part: 100
sub_part: 1
quantity: 10
raw_amount: '10'
allow_variants: True

# 40 x R_2K2_0805
Expand All @@ -16,6 +17,7 @@
part: 100
sub_part: 3
quantity: 40
raw_amount: '40'

# 25 x C_22N_0805
- model: part.bomitem
Expand All @@ -24,6 +26,7 @@
part: 100
sub_part: 5
quantity: 25
raw_amount: '25'
reference: ABCDE

# 3 x Orphan
Expand All @@ -33,6 +36,7 @@
part: 100
sub_part: 50
quantity: 3
raw_amount: '3'
reference: VWXYZ

- model: part.bomitem
Expand All @@ -41,6 +45,7 @@
part: 1
sub_part: 5
quantity: 3
raw_amount: '3'
reference: LMNOP

# Make "Assembly" from "Bob"
Expand All @@ -50,6 +55,7 @@
part: 101
sub_part: 100
quantity: 10
raw_amount: '10'

- model: part.bomitemsubstitute
pk: 1
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
70 changes: 64 additions & 6 deletions src/backend/InvenTree/part/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
59 changes: 37 additions & 22 deletions src/backend/InvenTree/part/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']

Expand All @@ -1660,6 +1660,7 @@ class Meta:
'part',
'sub_part',
'reference',
'raw_amount',
'quantity',
'allow_variants',
'inherited',
Expand Down Expand Up @@ -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)

Expand All @@ -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'),
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading