[API] Refactor stock adjustment endpoints (#12488)

This commit is contained in:
Oliver
2026-07-28 18:49:32 +10:00
committed by GitHub
parent 45f3810a8b
commit 1cee1bdefb
5 changed files with 76 additions and 22 deletions
@@ -76,6 +76,14 @@ class DiffMixin:
if field.name == 'id': if field.name == 'id':
continue continue
if field.is_relation:
# Compare the raw FK id first (no query) - only dereference to the
# full related object (one query each side) if it actually differs.
# In the common case (unchanged FK) this avoids two full-object
# fetches per relational field for every single save() call.
if getattr(self, field.attname) == getattr(db_instance, field.attname):
continue
if getattr(self, field.name) != getattr(db_instance, field.name): if getattr(self, field.name) != getattr(db_instance, field.name):
deltas[field.name] = { deltas[field.name] = {
'old': getattr(db_instance, field.name), 'old': getattr(db_instance, field.name),
@@ -282,7 +282,11 @@ class StatusCodeMixin:
- Ensure custom status code values are correctly updated - Ensure custom status code values are correctly updated
""" """
if self.status_class: # Only need to verify the custom key against the DB if one is actually set -
# the 'key' column on InvenTreeCustomUserStateModel is non-nullable, so a
# query for key=None (i.e. no custom status) can never match and would only
# ever result in a no-op (custom_key is already None in that case).
if self.status_class and self.get_custom_status() is not None:
# Check that the current 'logical key' actually matches the current status code # Check that the current 'logical key' actually matches the current status code
custom_values = self.status_class.custom_queryset().filter( custom_values = self.status_class.custom_queryset().filter(
logical_key=self.get_status(), key=self.get_custom_status() logical_key=self.get_status(), key=self.get_custom_status()
+55 -12
View File
@@ -1035,11 +1035,19 @@ class StockItem(
if type(self.batch) is str: if type(self.batch) is str:
self.batch = self.batch.strip() self.batch = self.batch.strip()
if not get_global_setting('STOCK_ALLOW_EDIT_SERIAL'): if not get_global_setting('STOCK_ALLOW_EDIT_SERIAL') and self.pk:
deltas = self.get_field_deltas()
# Prevent editing of serial numbers if the item already has a serial number assigned # Prevent editing of serial numbers if the item already has a serial number assigned
if 'serial' in deltas and deltas['serial']['old'] not in [None, '']: # Note: A targeted single-column lookup is used here (rather than get_field_deltas(),
# which fetches and diffs *every* field, dereferencing every non-null FK in the
# process) since only the previous 'serial' value is actually needed.
old_serial = (
StockItem.objects
.filter(pk=self.pk)
.values_list('serial', flat=True)
.first()
)
if old_serial not in [None, ''] and old_serial != self.serial:
raise ValidationError({ raise ValidationError({
'serial': _( 'serial': _(
'Editing of serial numbers is not allowed - this item has already been assigned a serial number' 'Editing of serial numbers is not allowed - this item has already been assigned a serial number'
@@ -3246,20 +3254,37 @@ class StockItem(
self._apply_model_reference_fields(kwargs, tracking_info) self._apply_model_reference_fields(kwargs, tracking_info)
quantity_updated = self.serialized or self.updateQuantity(count) # Will updateQuantity() below actually change (and therefore save) the row?
# Mirrors updateQuantity()'s own change check - used to decide whether the
# stocktake stamp can ride along on that save, avoiding a second write.
quantity_will_change = not self.serialized and count != self.quantity
# Stamp the stocktake metadata *before* updating the quantity, so that when
# updateQuantity() performs its own save (below), this stamp - and the status/
# location/reference field changes already applied above - are written in that
# single query, rather than needing a second, otherwise-redundant save() after.
if fields_updated or self.serialized or quantity_will_change:
self.stocktake_date = InvenTree.helpers.current_date()
self.stocktake_user = user
raw_update_result = None if self.serialized else self.updateQuantity(count)
quantity_updated = self.serialized or raw_update_result
# True only if updateQuantity() actually performed the save above
# (as opposed to: serialized item, no change, or item deleted)
update_persisted = raw_update_result is True
# Record the resulting quantity, whether or not this item survived the stocktake # Record the resulting quantity, whether or not this item survived the stocktake
# (self.quantity is updated by updateQuantity() even if the item was deleted) # (self.quantity is updated by updateQuantity() even if the item was deleted)
tracking_info['quantity'] = 1 if self.serialized else float(self.quantity) tracking_info['quantity'] = 1 if self.serialized else float(self.quantity)
# Save if the quantity or any other field was changed. # Save if the quantity or any other field was changed, and updateQuantity()
# didn't already do so above.
# Note that updateQuantity() may have *deleted* the item (depleted to zero), # Note that updateQuantity() may have *deleted* the item (depleted to zero),
# in which case there is nothing left to save. # in which case there is nothing left to save.
if self.pk and (quantity_updated or fields_updated): if self.pk and (quantity_updated or fields_updated):
self.stocktake_date = InvenTree.helpers.current_date() if not update_persisted:
self.stocktake_user = user self.save(add_note=False)
self.save(add_note=False)
trigger_event( trigger_event(
StockEvents.ITEM_COUNTED, StockEvents.ITEM_COUNTED,
@@ -3312,6 +3337,14 @@ class StockItem(
self._apply_status_change(status, tracking_info) self._apply_status_change(status, tracking_info)
self._apply_model_reference_fields(kwargs, tracking_info) self._apply_model_reference_fields(kwargs, tracking_info)
# Determine up-front whether any optional fields will actually change,
# so we can skip the second save() below when updateQuantity() (which
# already writes the full row, including the status/reference field
# changes applied above) has already persisted everything that matters.
optional_fields_changed = any(
field in kwargs for field in StockItem.optional_transfer_fields()
)
if self.updateQuantity(self.quantity + quantity): if self.updateQuantity(self.quantity + quantity):
tracking_info['added'] = float(quantity) tracking_info['added'] = float(quantity)
tracking_info['quantity'] = float(self.quantity) tracking_info['quantity'] = float(self.quantity)
@@ -3319,7 +3352,8 @@ class StockItem(
# Optional fields which can be supplied in a 'stocktake' call # Optional fields which can be supplied in a 'stocktake' call
self._apply_optional_transfer_fields(kwargs, tracking_info) self._apply_optional_transfer_fields(kwargs, tracking_info)
self.save(add_note=False) if optional_fields_changed:
self.save(add_note=False)
self.add_tracking_entry( self.add_tracking_entry(
StockHistoryCode.STOCK_ADD, StockHistoryCode.STOCK_ADD,
@@ -3372,6 +3406,14 @@ class StockItem(
self._apply_status_change(status, deltas) self._apply_status_change(status, deltas)
self._apply_model_reference_fields(kwargs, deltas) self._apply_model_reference_fields(kwargs, deltas)
# Determine up-front whether any optional fields will actually change,
# so we can skip the second save() below when updateQuantity() (which
# already writes the full row, including the status/reference field
# changes applied above) has already persisted everything that matters.
optional_fields_changed = any(
field in kwargs for field in StockItem.optional_transfer_fields()
)
quantity_updated = self.updateQuantity(self.quantity - quantity) quantity_updated = self.updateQuantity(self.quantity - quantity)
# Record the resulting quantity, whether or not this item survived the removal # Record the resulting quantity, whether or not this item survived the removal
@@ -3383,7 +3425,8 @@ class StockItem(
# Optional fields which can be supplied in a 'stocktake' call # Optional fields which can be supplied in a 'stocktake' call
self._apply_optional_transfer_fields(kwargs, deltas) self._apply_optional_transfer_fields(kwargs, deltas)
self.save(add_note=False) if optional_fields_changed:
self.save(add_note=False)
# Always record a tracking entry, even if the item was deleted as a result # Always record a tracking entry, even if the item was deleted as a result
# of this removal (e.g. depleted to zero with delete_on_deplete set) - # of this removal (e.g. depleted to zero with delete_on_deplete set) -
+4 -1
View File
@@ -1979,7 +1979,10 @@ class StockAdjustmentSerializer(serializers.Serializer):
if pks: if pks:
self.context['_stockitems'] = { self.context['_stockitems'] = {
obj.pk: obj for obj in StockItem.objects.filter(pk__in=pks) obj.pk: obj
for obj in StockItem.objects.filter(pk__in=pks).select_related(
'part', 'location'
)
} }
return super().to_internal_value(data) return super().to_internal_value(data)
+4 -8
View File
@@ -3092,9 +3092,8 @@ class StocktakeTest(StockAPITestCase):
with self.settings( with self.settings(
PLUGIN_TESTING_EVENTS=True, PLUGIN_TESTING_EVENTS_ASYNC=True PLUGIN_TESTING_EVENTS=True, PLUGIN_TESTING_EVENTS_ASYNC=True
): ):
# TODO: 2026-07-12 : Refactor this API call
response = self.post( response = self.post(
url, data, max_query_count=2250, benchmark=True, format='json' url, data, max_query_count=950, benchmark=True, format='json'
) )
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
@@ -3123,9 +3122,8 @@ class StocktakeTest(StockAPITestCase):
with self.settings( with self.settings(
PLUGIN_TESTING_EVENTS=True, PLUGIN_TESTING_EVENTS_ASYNC=True PLUGIN_TESTING_EVENTS=True, PLUGIN_TESTING_EVENTS_ASYNC=True
): ):
# TODO: 2026-07-12 : Refactor this API call
response = self.post( response = self.post(
url, data, max_query_count=2500, benchmark=True, format='json' url, data, max_query_count=950, benchmark=True, format='json'
) )
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
@@ -3154,9 +3152,8 @@ class StocktakeTest(StockAPITestCase):
with self.settings( with self.settings(
PLUGIN_TESTING_EVENTS=True, PLUGIN_TESTING_EVENTS_ASYNC=True PLUGIN_TESTING_EVENTS=True, PLUGIN_TESTING_EVENTS_ASYNC=True
): ):
# TODO: 2026-07-12 : Refactor this API call
response = self.post( response = self.post(
url, data, max_query_count=2250, benchmark=True, format='json' url, data, max_query_count=950, benchmark=True, format='json'
) )
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
@@ -3191,9 +3188,8 @@ class StocktakeTest(StockAPITestCase):
with self.settings( with self.settings(
PLUGIN_TESTING_EVENTS=True, PLUGIN_TESTING_EVENTS_ASYNC=True PLUGIN_TESTING_EVENTS=True, PLUGIN_TESTING_EVENTS_ASYNC=True
): ):
# TODO: 2026-07-12 : Refactor this API call
response = self.post( response = self.post(
url, data, max_query_count=1250, benchmark=True, format='json' url, data, max_query_count=850, benchmark=True, format='json'
) )
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)