diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 99e97c5b29..2ec8a9f592 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -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'), diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index 6e49a81749..810522e8b1 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -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.