mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-16 10:16:29 +00:00
* 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>
707 lines
25 KiB
Python
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
|