Fix StockItem parent update on delete (#12377)

* Fix StockItem parent update on delete

* Check for null parents
This commit is contained in:
Oliver 2026-07-14 16:29:30 +10:00 committed by GitHub
parent afbe3b3ab9
commit 70749ed93c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 77 additions and 2 deletions

View File

@ -550,6 +550,9 @@ class StockItem(
def delete(self, ignore_serial_check: bool = False, **kwargs):
"""Custom delete method for StockItem model.
Any child items are re-linked to the parent of this item,
to preserve the stock item genealogy chain.
Arguments:
ignore_serial_check: If True, allow deletion of serialized stock items regardless of global setting
"""
@ -559,7 +562,14 @@ class StockItem(
if self.serialized:
raise ValidationError(_('Serialized stock items cannot be deleted'))
super().delete(**kwargs)
with transaction.atomic():
# Re-link any child items to the parent of this item,
# so the genealogy chain survives deletion of an intermediate item
parent = StockItem.objects.filter(pk=self.parent_id).first()
StockItem.objects.filter(parent=self).update(parent=parent)
super().delete(**kwargs)
@staticmethod
def get_api_url():
@ -1036,7 +1046,7 @@ class StockItem(
"""Returns part name."""
return self.part.full_name
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
# Note: When a StockItem is deleted, child items are re-linked to its parent (see delete())
parent = models.ForeignKey(
'stock.StockItem',
verbose_name=_('Parent Stock Item'),

View File

@ -351,6 +351,71 @@ class StockTest(StockTestBase):
stock.splitStock(stock.quantity, None, self.user)
self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1)
def test_delete_reparents_children(self):
"""Test that deleting an intermediate item re-links children to the grandparent."""
grandparent = StockItem.objects.get(id=1234)
parent = grandparent.splitStock(200, None, self.user)
child_a = parent.splitStock(50, None, self.user)
child_b = parent.splitStock(50, None, self.user)
self.assertEqual(parent.parent, grandparent)
self.assertEqual(child_a.parent, parent)
self.assertEqual(child_b.parent, parent)
# Deleting the intermediate item grafts its children onto the grandparent
parent.delete()
child_a.refresh_from_db()
child_b.refresh_from_db()
self.assertEqual(child_a.parent, grandparent)
self.assertEqual(child_b.parent, grandparent)
# Deleting a top-level item leaves its children with no parent
grandparent.delete()
child_a.refresh_from_db()
child_b.refresh_from_db()
self.assertIsNone(child_a.parent)
self.assertIsNone(child_b.parent)
def test_implicit_delete_reparents_children(self):
"""Test child re-linking when items are deleted by depletion or merging."""
grandparent = StockItem.objects.get(id=1234)
# An item depleted to zero with delete_on_deplete set is deleted
parent = grandparent.splitStock(200, None, self.user)
child = parent.splitStock(50, None, self.user)
parent.delete_on_deplete = True
parent.save()
self.assertTrue(parent.take_stock(150, self.user, notes='Deplete'))
self.assertFalse(StockItem.objects.filter(pk=parent.pk).exists())
child.refresh_from_db()
self.assertEqual(child.parent, grandparent)
# An item absorbed by a merge is also deleted
source = grandparent.splitStock(100, None, self.user)
kid = source.splitStock(25, None, self.user)
target = StockItem.objects.create(
part=grandparent.part,
supplier_part=grandparent.supplier_part,
quantity=10,
location=grandparent.location,
)
target.merge_stock_items([source], raise_error=True, user=self.user)
self.assertFalse(StockItem.objects.filter(pk=source.pk).exists())
kid.refresh_from_db()
self.assertEqual(kid.parent, grandparent)
def test_over_adjustment_quantities(self):
"""Stock adjustments are clamped to the available stock quantity.