diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index f16b7e97f0..1db047143a 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -2068,7 +2068,14 @@ class Part( result_hash = hashlib.md5(str(self.id).encode()) # List *all* BOM items (including inherited ones!) - bom_items = self.get_bom_items().all().prefetch_related('part', 'sub_part') + # Note: We must order the BOM items in a consistent way, otherwise the hash will change if the order of the items changes + bom_items = ( + self + .get_bom_items() + .all() + .prefetch_related('part', 'sub_part') + .order_by('pk') + ) for item in bom_items: result_hash.update(str(item.get_item_hash()).encode()) @@ -2141,7 +2148,7 @@ class Part( if parts is None: parts = set() - bom_items = self.get_bom_items() + bom_items = self.get_bom_items().prefetch_related('sub_part') for bom_item in bom_items: sub_part = bom_item.sub_part diff --git a/src/backend/InvenTree/part/test_bom_item.py b/src/backend/InvenTree/part/test_bom_item.py index e72e3442c0..295c6c7027 100644 --- a/src/backend/InvenTree/part/test_bom_item.py +++ b/src/backend/InvenTree/part/test_bom_item.py @@ -1,5 +1,8 @@ """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 @@ -460,6 +463,59 @@ class BomItemTest(TestCase): 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