From d9b9a87893acb01f7a5d34cb24d1bb5b94edcd0a Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 14 Jul 2026 10:25:27 +1000 Subject: [PATCH] [bug] Quantity fixes (#12375) - Ensure valid quantity values on stock operations - Regression testing --- src/backend/InvenTree/stock/models.py | 25 +++++++- src/backend/InvenTree/stock/tests.py | 86 +++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 73042aeacf..98d7f3b0da 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -1376,9 +1376,16 @@ class StockItem( user: User that performed the action notes: Notes field """ - if quantity is None: + if quantity is None or self.serialized: quantity = self.quantity + # Cannot allocate more than the available quantity + # (also ensures the recorded history matches the actual allocation) + quantity = min(quantity, self.quantity) + + if quantity <= 0: + raise ValidationError({'quantity': _('Quantity must be greater than zero')}) + if quantity >= self.quantity: item = self else: @@ -1708,6 +1715,18 @@ class StockItem( notes: Any notes associated with the operation build: The BuildOrder to associate with the operation (optional) """ + try: + quantity = Decimal(quantity) + except (InvalidOperation, TypeError): + raise ValidationError({'quantity': _('Invalid quantity value')}) + + if quantity <= 0: + raise ValidationError({'quantity': _('Quantity must be greater than zero')}) + + # Cannot install more than the available quantity + # (also ensures the recorded history matches the actual installation) + quantity = min(quantity, other_item.quantity) + # If the quantity is less than the stock item, split the stock! stock_item = other_item.splitStock(quantity, None, user) @@ -3065,6 +3084,10 @@ class StockItem( except InvalidOperation: return False + # Cannot remove more than the available quantity + # (also ensures the recorded history matches the actual removal) + quantity = min(quantity, self.quantity) + if quantity <= 0: return False diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index 63ed32321a..0d402638b2 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -351,6 +351,92 @@ class StockTest(StockTestBase): stock.splitStock(stock.quantity, None, self.user) self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1) + def test_over_adjustment_quantities(self): + """Stock adjustments are clamped to the available stock quantity. + + Regression test: take_stock / allocateToCustomer / installStockItem + previously recorded the *requested* quantity in the stock history, + even when less stock was actually removed / allocated / installed. + """ + part = Part.objects.create( + name='Clamp part', + description='A part for quantity clamping tests', + salable=True, + component=True, + ) + + # --- take_stock: remove more than available --- + item = StockItem.objects.create(part=part, quantity=10, delete_on_deplete=False) + + self.assertTrue(item.take_stock(25, self.user, notes='over-remove')) + + item.refresh_from_db() + self.assertEqual(item.quantity, 0) + + # History records the quantity which was *actually* removed + entry = item.tracking_info.latest('pk') + self.assertEqual(entry.deltas['removed'], 10.0) + self.assertEqual(entry.deltas['quantity'], 0.0) + + # Removing stock from an empty item fails, and adds no history + n = item.tracking_info.count() + self.assertFalse(item.take_stock(5, self.user)) + self.assertEqual(item.tracking_info.count(), n) + + # --- allocateToCustomer: allocate more than available --- + customer = Company.objects.create(name='Clamp customer', is_customer=True) + + item = StockItem.objects.create(part=part, quantity=10) + + allocated = item.allocateToCustomer(customer, quantity=25, user=self.user) + + # The whole item is allocated (no split occurs) + self.assertEqual(allocated.pk, item.pk) + self.assertEqual(allocated.customer, customer) + + # History records the quantity which was *actually* allocated + entry = allocated.tracking_info.latest('pk') + self.assertEqual(entry.deltas['quantity'], 10.0) + + # A zero quantity is rejected outright + item = StockItem.objects.create(part=part, quantity=10) + + with self.assertRaises(ValidationError): + item.allocateToCustomer(customer, quantity=0, user=self.user) + + # --- installStockItem: install more than available --- + assembly = Part.objects.create( + name='Clamp assembly', + description='An assembly for quantity clamping tests', + assembly=True, + ) + + parent_item = StockItem.objects.create(part=assembly, quantity=1) + component = StockItem.objects.create(part=part, quantity=10) + + parent_item.installStockItem(component, 25, self.user, 'over-install') + + component.refresh_from_db() + self.assertEqual(component.belongs_to, parent_item) + self.assertEqual(component.quantity, 10) + + # Both history entries record the quantity which was *actually* installed + entry = component.tracking_info.filter( + tracking_type=StockHistoryCode.INSTALLED_INTO_ASSEMBLY + ).latest('pk') + self.assertEqual(entry.deltas['quantity'], 10.0) + + entry = parent_item.tracking_info.filter( + tracking_type=StockHistoryCode.INSTALLED_CHILD_ITEM + ).latest('pk') + self.assertEqual(entry.deltas['quantity'], 10.0) + + # A zero quantity is rejected outright + other = StockItem.objects.create(part=part, quantity=5) + + with self.assertRaises(ValidationError): + parent_item.installStockItem(other, 0, self.user, 'bad install') + def test_uninstall_into_structural_location(self): """Test that an item cannot be uninstalled into a structural location.""" parent = StockItem.objects.get(pk=1)