[bug] Quantity fixes (#12375)

- Ensure valid quantity values on stock operations
- Regression testing
This commit is contained in:
Oliver 2026-07-14 10:25:27 +10:00 committed by GitHub
parent b64182b565
commit d9b9a87893
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 110 additions and 1 deletions

View File

@ -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

View File

@ -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)