Files
InvenTree/src/backend/InvenTree/part/test_bom_item.py
T
amanjain57-gifandAman Jain ee4ad7fd10 feat: add piece_count and piece_size fields to BomItem for cut-to-length parts (#12422)
* feat: add piece_count and piece_size fields to BomItem for cut-to-length parts

Manufacturing BOMs frequently require multiple pieces of a specific size
cut from continuous stock (cables, tubing, structural profiles). Currently
the only way to express "10 pieces of 250mm cable" is to enter the total
length (2.5m) as quantity, which loses the piece-count information that
purchasing and production need.

This adds two optional fields to BomItem:
- piece_count: number of discrete pieces required (default: 1)
- piece_size: size/length of each piece (e.g. "250 mm")

When piece_size is specified, the total quantity is auto-calculated as
piece_count × piece_size, maintaining full backward compatibility (existing
items effectively have piece_count=1 and empty piece_size).

Changes:
- Backend: new model fields, migration, updated recalculate_quantity()
  logic, hash_fields for BOM validation
- API: serializer exposes piece_count and piece_size
- Frontend: BOM form includes the new fields, BOM table shows them as
  optional columns

Addresses #10274

* refactor: simplify to single piece_count field per reviewer feedback

Remove the piece_size field entirely. The existing quantity field already
represents the per-piece size/length, so piece_count multiplied by
quantity gives the total material requirement.

Example: quantity=200mm, piece_count=10 → total 2m of wire in 10 pieces.

Changes:
- Remove piece_size model field, serializer field, and frontend column/form
- Update migration to only add piece_count
- Update get_required_quantity() to multiply by piece_count
- Restore original recalculate_quantity() without piece_size logic

* test/docs: add unit tests and documentation for piece_count field

* style: replace ambiguous × with x to fix RUF002 lint error

* Address review feedback: api_version bump, changelog, style fix

- Bump INVENTREE_API_VERSION to 531 with entry for piece_count field
- Add CHANGELOG.md entry under Unreleased > Added
- Fix RUF001: replace ambiguous × with x in serializers.py help_text

* fix: align piece_count migration help_text with model (RUF001)

The 0153 AddField recorded help_text with a Unicode multiplication sign
(×), while the model field uses plain 'x' after the RUF001 fix. This
mismatch made makemigrations --check flag an unstaged
0154_alter_bomitem_piece_count migration, failing the DB test CI jobs.

Update the original migration's help_text (and docstring) to plain 'x'
so the field definition matches the model, keeping a single clean
migration instead of add-then-alter.

* fix: use set_quantity() in piece_count tests

BomItem.quantity is a derived field, recalculated from raw_amount on
every save() via recalculate_quantity(). Setting item.quantity directly
was overwritten back to the fixture value on save, so the tests computed
against quantity=3 and failed. Use set_quantity() (which sets raw_amount)
to match how quantity is meant to be updated.

* ci: re-trigger CI to confirm Firefox E2E failures are transient

---------

Co-authored-by: Aman Jain <jainamn@amazon.com>
2026-08-16 09:43:42 +10:00

707 lines
25 KiB
Python

"""Unit tests for the BomItem model."""
import hashlib
from unittest import mock
import django.core.exceptions as django_exceptions
from django.db import transaction
from django.test import TestCase
import build.models
import stock.models
from common.settings import set_global_setting
from .models import BomItem, BomItemSubstitute, Part
class BomItemTest(TestCase):
"""Class for unit testing BomItem model."""
fixtures = [
'category',
'part',
'location',
'bom',
'company',
'supplier_part',
'part_pricebreaks',
'price_breaks',
]
def setUp(self):
"""Create initial data."""
super().setUp()
self.bob = Part.objects.get(id=100)
self.orphan = Part.objects.get(name='Orphan')
self.r1 = Part.objects.get(name='R_2K2_0805')
def test_str(self):
"""Test the string representation of a BOMItem."""
b = BomItem.objects.get(id=1)
self.assertEqual(str(b), '10 x M2x4 LPHS to make BOB | Bob | A2')
def test_has_bom(self):
"""Test the has_bom attribute."""
self.assertFalse(self.orphan.has_bom)
self.assertTrue(self.bob.has_bom)
self.assertEqual(self.bob.bom_count, 4)
def test_in_bom(self):
"""Test BOM aggregation."""
parts = self.bob.getRequiredParts()
self.assertIn(self.orphan, parts)
self.assertTrue(self.bob.check_if_part_in_bom(self.orphan))
def test_used_in(self):
"""Test that the 'used_in_count' attribute is calculated correctly."""
self.assertEqual(self.bob.used_in_count, 1)
self.assertEqual(self.orphan.used_in_count, 1)
def test_self_reference(self):
"""Test that we get an appropriate error when we create a BomItem which points to itself."""
with self.assertRaises(django_exceptions.ValidationError):
# A validation error should be raised here
item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7)
item.clean() # pragma: no cover
def test_integer_quantity(self):
"""Test integer validation for BomItem."""
p = Part.objects.create(
name='test', description='part description', component=True, trackable=True
)
# Creation of a BOMItem with a non-integer quantity of a trackable Part should fail
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=self.bob, sub_part=p, quantity=21.7)
# But with an integer quantity, should be fine
BomItem.objects.create(part=self.bob, sub_part=p, quantity=21)
def test_attrition(self):
"""Test that BOM line attrition values are calculated correctly."""
item = BomItem.objects.get(part=100, sub_part=50)
item.quantity = 300
item.attrition = 5 # 5% attrition
# Calculate total required quantity
# Quantity = 300 (+ 5%)
# Get quantity required to build B = 10
# Q * B = 3000 + 5% = 3150
n = item.get_required_quantity(10)
self.assertEqual(n, 3150)
def test_setup_quantity(self):
"""Test the 'setup_quantity' attribute."""
item = BomItem.objects.get(pk=4)
# Default is 0
self.assertEqual(item.setup_quantity, 0)
self.assertEqual(item.get_required_quantity(1), 3)
self.assertEqual(item.get_required_quantity(10), 30)
item.setup_quantity = 5
item.save()
# Now the required quantity should include the setup quantity
self.assertEqual(item.get_required_quantity(1), 8) # 3 + 5 = 8
self.assertEqual(item.get_required_quantity(10), 35) # 30 + 5 = 35
def test_round_up(self):
"""Test the 'rounding_multiple' attribute."""
item = BomItem.objects.get(pk=4)
# Default is null
self.assertIsNone(item.rounding_multiple)
self.assertEqual(item.get_required_quantity(1), 3) # 3 x 1 = 3
self.assertEqual(item.get_required_quantity(10), 30) # 3 x 10 = 30
self.assertEqual(item.get_required_quantity(25), 75) # 3 x 25 = 75
# Set a round-up multiple
item.rounding_multiple = 17
item.save()
# Now the required quantity should be rounded up to the nearest multiple of 17
self.assertEqual(
item.get_required_quantity(1), 17
) # 3 x 1 = 3, rounded up to nearest multiple of 17
self.assertEqual(
item.get_required_quantity(2), 17
) # 3 x 2 = 6, rounded up to nearest multiple of 17
self.assertEqual(
item.get_required_quantity(5), 17
) # 3 x 5 = 15, rounded up to nearest multiple of 17
self.assertEqual(
item.get_required_quantity(10), 34
) # 3 x 10 = 30, rounded up to nearest multiple of 17
self.assertEqual(
item.get_required_quantity(100), 306
) # 3 x 100 = 300, rounded up to nearest multiple of 17
# Next, let's create a new Build order
bo = build.models.Build.objects.create(
part=item.part, quantity=21, reference='BO-9999', title='Test Build Order'
)
# Build line items have been auto created
lines = bo.build_lines.all().filter(bom_item=item)
self.assertEqual(lines.count(), 1)
line = lines.first()
self.assertEqual(
line.quantity, 68
) # 3 x 21 = 63, rounded up to nearest multiple of 17
def test_item_hash(self):
"""Test BOM item hash encoding."""
item = BomItem.objects.get(part=100, sub_part=50)
h1 = item.get_item_hash()
# Change data - the hash must change
item.quantity += 1
h2 = item.get_item_hash()
item.validate_hash()
self.assertNotEqual(h1, h2)
def test_substitutes(self):
"""Tests for BOM item substitutes."""
# We will make some substitute parts for the "orphan" part
bom_item = BomItem.objects.get(part=self.bob, sub_part=self.orphan)
# No substitute parts available
self.assertEqual(bom_item.substitutes.count(), 0)
subs = []
for ii in range(5):
# Create a new part
sub_part = Part.objects.create(
name=f'Orphan {ii}',
description='A substitute part for the orphan part',
component=True,
is_template=False,
assembly=False,
)
subs.append(sub_part)
# Link it as a substitute part
BomItemSubstitute.objects.create(bom_item=bom_item, part=sub_part)
# Try to link it again (this should fail as it is a duplicate substitute)
with self.assertRaises(django_exceptions.ValidationError):
with transaction.atomic():
BomItemSubstitute.objects.create(bom_item=bom_item, part=sub_part)
# There should be now 5 substitute parts available
self.assertEqual(bom_item.substitutes.count(), 5)
# Try to create a substitute which points to the same sub-part (should fail)
with self.assertRaises(django_exceptions.ValidationError):
BomItemSubstitute.objects.create(bom_item=bom_item, part=self.orphan)
# Remove one substitute part
bom_item.substitutes.last().delete()
self.assertEqual(bom_item.substitutes.count(), 4)
for sub in subs:
sub.active = False
sub.save()
sub.delete()
# The substitution links should have been automatically removed
self.assertEqual(bom_item.substitutes.count(), 0)
def test_consumable(self):
"""Tests for the 'consumable' BomItem field."""
# Create an assembly part
assembly = Part.objects.create(
name='An assembly', description='Made with parts', assembly=True
)
# No BOM information initially
self.assertEqual(assembly.can_build, 0)
# Create some component items
c1 = Part.objects.create(
name='C1', description='Part C1 - this is just the part description'
)
c2 = Part.objects.create(
name='C2', description='Part C2 - this is just the part description'
)
c3 = Part.objects.create(
name='C3', description='Part C3 - this is just the part description'
)
c4 = Part.objects.create(
name='C4', description='Part C4 - this is just the part description'
)
for p in [c1, c2, c3, c4]:
# Ensure we have stock
stock.models.StockItem.objects.create(part=p, quantity=1000)
# Create some BOM items
BomItem.objects.create(part=assembly, sub_part=c1, quantity=10)
self.assertEqual(assembly.can_build, 100)
BomItem.objects.create(part=assembly, sub_part=c2, quantity=50, consumable=True)
# A 'consumable' BomItem does not alter the can_build calculation
self.assertEqual(assembly.can_build, 100)
BomItem.objects.create(part=assembly, sub_part=c3, quantity=50)
self.assertEqual(assembly.can_build, 20)
# Mark 'c4' as consumable at the *part* level (not the BOM line itself)
c4.consumable = True
c4.save()
bom_item_c4 = BomItem.objects.create(part=assembly, sub_part=c4, quantity=200)
# The raw BomItem field is unset, but the part is marked as consumable
self.assertFalse(bom_item_c4.consumable)
self.assertTrue(bom_item_c4.is_consumable)
# A BomItem which is consumable via its part does not alter the can_build calculation
self.assertEqual(assembly.can_build, 20)
def test_consumable_filter(self):
"""Tests for the BomItem.consumable_filter() helper method."""
assembly = Part.objects.create(
name='Another assembly', description='Made with parts', assembly=True
)
c1 = Part.objects.create(name='D1', description='Not consumable')
c2 = Part.objects.create(name='D2', description='Consumable BOM line')
c3 = Part.objects.create(
name='D3', description='Consumable part', consumable=True
)
bom_item_1 = BomItem.objects.create(part=assembly, sub_part=c1, quantity=1)
bom_item_2 = BomItem.objects.create(
part=assembly, sub_part=c2, quantity=1, consumable=True
)
bom_item_3 = BomItem.objects.create(part=assembly, sub_part=c3, quantity=1)
consumable_items = set(
BomItem.objects.filter(BomItem.consumable_filter(consumable=True))
)
non_consumable_items = set(
BomItem.objects.filter(BomItem.consumable_filter(consumable=False))
)
self.assertIn(bom_item_2, consumable_items)
self.assertIn(bom_item_3, consumable_items)
self.assertNotIn(bom_item_1, consumable_items)
self.assertIn(bom_item_1, non_consumable_items)
self.assertNotIn(bom_item_2, non_consumable_items)
self.assertNotIn(bom_item_3, non_consumable_items)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [BomItem]:
p = model.objects.first()
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)
def test_invalid_bom(self):
"""Test that ValidationError is correctly raised for an invalid BOM item."""
# First test: A BOM item which points to itself
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=1)
# Second test: A recursive BOM
part_a = Part.objects.create(
name='Part A',
description='A part which is called A',
assembly=True,
is_template=True,
component=True,
)
part_b = Part.objects.create(
name='Part B',
description='A part which is called B',
assembly=True,
component=True,
)
part_c = Part.objects.create(
name='Part C',
description='A part which is called C',
assembly=True,
component=True,
)
BomItem.objects.create(part=part_a, sub_part=part_b, quantity=10)
BomItem.objects.create(part=part_b, sub_part=part_c, quantity=10)
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=part_c, sub_part=part_a, quantity=10)
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=part_c, sub_part=part_b, quantity=10)
# Third test: A recursive BOM with a variant part
part_v = Part.objects.create(
name='Part V',
description='A part which is called V',
variant_of=part_a,
assembly=True,
component=True,
)
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=part_a, sub_part=part_v, quantity=10)
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=part_v, sub_part=part_a, quantity=10)
def test_locked_assembly(self):
"""Test that BomItem objects work correctly for a 'locked' assembly."""
assembly = Part.objects.create(
name='Assembly2', description='An assembly part', assembly=True
)
sub_part = Part.objects.create(
name='SubPart1', description='A sub-part', component=True
)
# Initially, the assembly is not locked
self.assertFalse(assembly.locked)
# Create a BOM item for the assembly
bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
# Lock the assembly
assembly.locked = True
assembly.save()
# Try to edit the BOM item
with self.assertRaises(django_exceptions.ValidationError):
bom_item.quantity = 10
bom_item.save()
# Try to delete the BOM item
with self.assertRaises(django_exceptions.ValidationError):
bom_item.delete()
# Try to create a new BOM item
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
# Unlock the part and try again
assembly.locked = False
assembly.save()
# Create a new BOM item
bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
# Edit the new BOM item
bom_item.quantity = 10
bom_item.save()
# Delete the new BOM item
bom_item.delete()
def test_locked_assembly_locking_disabled(self):
"""Test that a locked assembly is not enforced when PART_ENABLE_LOCKING is disabled."""
assembly = Part.objects.create(
name='Assembly3', description='An assembly part', assembly=True
)
sub_part = Part.objects.create(
name='SubPart2', description='A sub-part', component=True
)
bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
assembly.locked = True
assembly.save()
# With locking enabled (default), editing is blocked
with self.assertRaises(django_exceptions.ValidationError):
bom_item.quantity = 5
bom_item.save()
# Disable locking globally — all BOM operations should now be allowed
set_global_setting('PART_ENABLE_LOCKING', False)
bom_item.quantity = 5
bom_item.save()
BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=2)
bom_item.delete()
# Re-enable for other tests
set_global_setting('PART_ENABLE_LOCKING', True)
# Confirm locking is enforced again
bom_item2 = BomItem.objects.get(part=assembly, sub_part=sub_part, quantity=2)
with self.assertRaises(django_exceptions.ValidationError):
bom_item2.quantity = 99
bom_item2.save()
def test_bom_hash_order_consistency(self):
"""Regression test for BOM checksum instability due to non-deterministic item ordering.
See: https://github.com/inventree/InvenTree/issues/12445
get_bom_hash() must apply an explicit, stable ordering when iterating the BOM
items - otherwise the resulting hash can differ purely because the underlying
query returned rows in a different order (e.g. Postgres provides no ordering
guarantee unless an ORDER BY clause is specified).
"""
assembly = Part.objects.create(
name='HashOrderAssembly', description='An assembly part', assembly=True
)
for ii in range(5):
sub_part = Part.objects.create(
name=f'HashOrderPart{ii}',
description='A sub-part for hash ordering test',
component=True,
)
BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=ii + 1)
# Calling get_bom_hash() repeatedly must always return the same value
h1 = assembly.get_bom_hash()
h2 = assembly.get_bom_hash()
self.assertEqual(h1, h2)
def hash_items(items) -> str:
"""Replicate the hashing logic of get_bom_hash(), for a given item order."""
result_hash = hashlib.md5(str(assembly.id).encode())
for item in items:
result_hash.update(str(item.get_item_hash()).encode())
return str(result_hash.digest())
items_forward = list(assembly.get_bom_items().order_by('pk'))
items_reverse = list(assembly.get_bom_items().order_by('-pk'))
self.assertEqual(items_forward, list(reversed(items_reverse)))
# Sanity check: hashing the *same* items in a different order produces a
# different result - so ordering genuinely matters here
self.assertNotEqual(hash_items(items_forward), hash_items(items_reverse))
# Simulate the underlying queryset returning BOM items in a non pk-ascending
# order (as could occur against a real database with no ORDER BY applied).
# get_bom_hash() must be unaffected, always normalizing to the same order.
reversed_queryset = assembly.get_bom_items().order_by('-pk')
with mock.patch.object(Part, 'get_bom_items', return_value=reversed_queryset):
hash_from_reversed_source = assembly.get_bom_hash()
self.assertEqual(hash_from_reversed_source, hash_items(items_forward))
def test_bom_validated(self):
"""Test for caching of 'bom_validated' property."""
from part.tasks import validate_bom
assembly = Part.objects.create(
name='Assembly1', description='An assembly part', assembly=True
)
assembly_2 = Part.objects.create(
name='Assembly2', description='An assembly part', assembly=True
)
def check(valid: bool = True):
"""Helper function to check the BOM for this assembly."""
nonlocal assembly
assembly.refresh_from_db()
self.assertEqual(assembly.bom_validated, valid)
def validate(valid: bool = True):
"""Helper function to validate the BOM for this assembly."""
nonlocal assembly
validate_bom(assembly.pk, valid)
check(valid)
check(valid=False)
validate()
sub_part_1 = Part.objects.create(
name='SubPart1', description='A sub-part', component=True
)
sub_part_2 = Part.objects.create(
name='SubPart2', description='A sub-part', component=True
)
# Still valid at this stage - we have not made any changes to the BOM
check(valid=True)
# Creating a *new* BOM item should invalidate the bom_validated cache
bom_item = BomItem.objects.create(
part=assembly, sub_part=sub_part_1, quantity=1
)
check(valid=False)
# Editing the BOM item should also invalidate the bom_validated cache
validate()
bom_item.set_quantity(2)
bom_item.save()
check(valid=False)
# Editing the BOM item without changing any relevant fields should not invalidate the bom_validated cache
validate()
bom_item.description = 'This is a description'
bom_item.save()
check(valid=True)
# Point the BOM item to a different component
validate()
bom_item.sub_part = sub_part_2
bom_item.save()
check(valid=False)
# Point the BOM to a different assembly
validate()
bom_item.part = assembly_2
bom_item.save()
check(valid=False)
# Check a partial restore - returning to previous state should re-validate
bom_item.part = assembly
bom_item.save()
check(valid=True)
# Now, delete the BomItem entirely
bom_item.delete()
check(valid=False)
self.assertIsNotNone(assembly.bom_checked_date)
def test_piece_count_default(self):
"""Test that piece_count defaults to 1 and does not change existing behavior."""
item = BomItem.objects.get(part=100, sub_part=50)
# Default value should be 1
self.assertEqual(item.piece_count, 1)
# With piece_count=1, get_required_quantity should behave as before
item.set_quantity(10)
item.attrition = 0
item.setup_quantity = 0
item.rounding_multiple = None
item.save()
# 10 * 1 (piece_count) * 5 (build_quantity) = 50
self.assertEqual(item.get_required_quantity(5), 50)
def test_piece_count_multiplier(self):
"""Test that piece_count correctly multiplies the required quantity.
Example: Cutting wire into 200mm lengths, need 10 pieces per assembly.
quantity=200 (mm per piece), piece_count=10, build_quantity=5
Total = 200 * 10 * 5 = 10000 mm
"""
item = BomItem.objects.get(part=100, sub_part=50)
item.set_quantity(200)
item.piece_count = 10
item.attrition = 0
item.setup_quantity = 0
item.rounding_multiple = None
item.save()
# 200 * 10 * 5 = 10000
self.assertEqual(item.get_required_quantity(5), 10000)
# 200 * 10 * 1 = 2000
self.assertEqual(item.get_required_quantity(1), 2000)
# 200 * 10 * 10 = 20000
self.assertEqual(item.get_required_quantity(10), 20000)
def test_piece_count_with_attrition(self):
"""Test piece_count combined with attrition percentage."""
item = BomItem.objects.get(part=100, sub_part=50)
item.set_quantity(100)
item.piece_count = 5
item.attrition = 10 # 10% attrition
item.setup_quantity = 0
item.rounding_multiple = None
item.save()
# Base: 100 * 5 * 2 = 1000
# With 10% attrition: 1000 * 1.10 = 1100
self.assertEqual(item.get_required_quantity(2), 1100)
def test_piece_count_with_setup_quantity(self):
"""Test piece_count combined with setup_quantity."""
item = BomItem.objects.get(part=100, sub_part=50)
item.set_quantity(50)
item.piece_count = 4
item.attrition = 0
item.setup_quantity = 20
item.rounding_multiple = None
item.save()
# Base: 50 * 4 * 3 = 600
# With setup_quantity: 600 + 20 = 620
self.assertEqual(item.get_required_quantity(3), 620)
def test_piece_count_with_rounding(self):
"""Test piece_count combined with rounding_multiple."""
item = BomItem.objects.get(part=100, sub_part=50)
item.set_quantity(7)
item.piece_count = 3
item.attrition = 0
item.setup_quantity = 0
item.rounding_multiple = 25
item.save()
# Base: 7 * 3 * 2 = 42
# Rounded up to nearest multiple of 25: 50
self.assertEqual(item.get_required_quantity(2), 50)
def test_piece_count_validation(self):
"""Test that piece_count rejects invalid values (0, negative)."""
item = BomItem.objects.get(part=100, sub_part=50)
# piece_count = 0 should be rejected (MinValueValidator(1))
item.piece_count = 0
with self.assertRaises(django_exceptions.ValidationError):
item.full_clean()
# piece_count = -1 should also be rejected
item.piece_count = -1
with self.assertRaises(django_exceptions.ValidationError):
item.full_clean()
# piece_count = 1 is the minimum valid value
item.piece_count = 1
item.full_clean() # Should not raise
# piece_count = 100 is a valid value
item.piece_count = 100
item.full_clean() # Should not raise