[bug] BOM checksum fix (#12455)

* Enforce ordering of BOM items

* Add prefetch to getRequiredParts

* Add regression test
This commit is contained in:
Oliver
2026-07-23 21:44:04 +10:00
committed by GitHub
parent 65efa51524
commit 829a4462c7
2 changed files with 65 additions and 2 deletions
+9 -2
View File
@@ -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
@@ -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