[refactor] Refactor StockItem methods (#12358)

* Refactor StockItem methods

- Refactor out common functionality
- Code cleanup

* Cleanup

* Prevent override of existing deltas
This commit is contained in:
Oliver
2026-07-11 18:48:37 +10:00
committed by GitHub
parent 4bff83108f
commit 35eb839f08
2 changed files with 170 additions and 159 deletions
+166 -159
View File
@@ -1921,11 +1921,8 @@ class StockItem(
): ):
return None return None
# Has a location been specified? # Extract any additional information from the kwargs
location = kwargs.get('location') self._apply_model_reference_fields(deltas, kwargs)
if location:
deltas['location'] = location.id
# Quantity specified? # Quantity specified?
quantity = kwargs.get('quantity') quantity = kwargs.get('quantity')
@@ -1933,8 +1930,11 @@ class StockItem(
if quantity: if quantity:
deltas['quantity'] = float(quantity) deltas['quantity'] = float(quantity)
# If this item has already been deleted (e.g. depleted via delete_on_deplete),
# self.pk is None - Django refuses to save a FK pointing at an unsaved instance,
# even for a nullable/SET_NULL field, so pass None explicitly instead of self.
entry = StockItemTracking( entry = StockItemTracking(
item=self, item=self if self.pk else None,
part=self.part, part=self.part,
tracking_type=entry_type.value, tracking_type=entry_type.value,
user=user, user=user,
@@ -2310,11 +2310,10 @@ class StockItem(
'added': float(merged_quantity), 'added': float(merged_quantity),
} }
if location: self._apply_model_reference_fields(kwargs, tracking_deltas)
tracking_deltas['location'] = location.pk
if transfer_deltas: if transfer_deltas:
tracking_deltas = {**transfer_deltas, **tracking_deltas} tracking_deltas.update(transfer_deltas)
self.add_tracking_entry( self.add_tracking_entry(
StockHistoryCode.MERGED_STOCK_ITEMS, StockHistoryCode.MERGED_STOCK_ITEMS,
@@ -2384,9 +2383,11 @@ class StockItem(
allow_production: If True, allow splitting of stock which is in production (default = False) allow_production: If True, allow splitting of stock which is in production (default = False)
record_tracking: If False, skip tracking entries (for merge-on-transfer) record_tracking: If False, skip tracking entries (for merge-on-transfer)
split_transfer_deltas: Optional dict to receive split tracking deltas split_transfer_deltas: Optional dict to receive split tracking deltas
copy_test_results: If True, copy test results from this item to the new one (default = True)
Returns: Returns:
The new StockItem object The new StockItem object, or None if no split occurred (e.g. invalid
quantity, or this item is serialized)
Raises: Raises:
ValidationError: If the stock item cannot be split ValidationError: If the stock item cannot be split
@@ -2394,6 +2395,10 @@ class StockItem(
- The provided quantity will be subtracted from this item and given to the new one. - The provided quantity will be subtracted from this item and given to the new one.
- The new item will have a different StockItem ID, while this will remain the same. - The new item will have a different StockItem ID, while this will remain the same.
""" """
# Do not split a serialized part
if self.serialized:
return None
# Run initial checks to test if the stock item can actually be "split" # Run initial checks to test if the stock item can actually be "split"
allow_production = kwargs.get('allow_production', False) allow_production = kwargs.get('allow_production', False)
record_tracking = kwargs.pop('record_tracking', True) record_tracking = kwargs.pop('record_tracking', True)
@@ -2403,70 +2408,45 @@ class StockItem(
if self.is_building and not allow_production: if self.is_building and not allow_production:
raise ValidationError(_('Stock item is currently in production')) raise ValidationError(_('Stock item is currently in production'))
notes = kwargs.get('notes', '')
# Do not split a serialized part
if self.serialized:
return self
try: try:
# Exit early if the provided quantity is invalid
quantity = Decimal(quantity) quantity = Decimal(quantity)
except (InvalidOperation, ValueError): except (InvalidOperation, ValueError):
return self logger.error(
'StockItem<%s>.split_stock - invalid quantity (%s)', self.pk, quantity
)
InvenTree.exceptions.log_error('StockItem.split_stock')
return None
# Doesn't make sense for a zero quantity # Doesn't make sense for a zero quantity
if quantity <= 0: if quantity <= 0:
return self return None
# Also doesn't make sense to split the full amount # Also doesn't make sense to split the full amount
if quantity >= self.quantity: if quantity >= self.quantity:
return self return None
# Create a new StockItem object, duplicating relevant fields # Create a new StockItem object, duplicating relevant fields
# Nullify the PK so a new record is created # Nullify the PK so a new record is created
new_stock = StockItem.objects.get(pk=self.pk) new_stock = StockItem.objects.get(pk=self.pk)
new_stock.pk = None new_stock.pk = None
new_stock.quantity = quantity new_stock.quantity = quantity
new_stock.location = location or self.location
# Update the new stock item to ensure the tree structure is observed # Update the new stock item to ensure the tree structure is observed
new_stock.parent = self new_stock.parent = self
new_stock.tree_id = None new_stock.tree_id = None
# Move to the new location if specified, otherwise use current location # Create 'deltas' dict to record changes for tracking
if location:
new_stock.location = location
else:
new_stock.location = self.location
deltas = {'stockitem': self.pk} deltas = {'stockitem': self.pk}
transferorder = kwargs.pop('transferorder', None) new_stock._apply_model_reference_fields(kwargs, deltas)
if transferorder:
deltas['transferorder'] = transferorder.pk status = new_stock._resolve_status_kwarg(kwargs)
new_stock._apply_status_change(status, deltas)
# Optional fields which can be supplied in a 'move' call # Optional fields which can be supplied in a 'move' call
for field in StockItem.optional_transfer_fields(): new_stock._apply_optional_transfer_fields(kwargs, deltas)
if field in kwargs:
# handle specific case for status deltas
if field == 'status':
status = kwargs[field]
if not new_stock.compare_status(status):
old_custom_status = new_stock.get_custom_status()
old_status_logical = new_stock.status
new_stock.set_status(status)
deltas['status'] = status # may be a custom value
deltas['status_logical'] = (
new_stock.status
) # always the logical value
deltas['old_status'] = (
old_custom_status
if old_custom_status
else old_status_logical
)
deltas['old_status_logical'] = old_status_logical
else:
setattr(new_stock, field, kwargs[field])
deltas[field] = kwargs[field]
new_stock.save(add_note=False) new_stock.save(add_note=False)
@@ -2483,20 +2463,21 @@ class StockItem(
StockHistoryCode.SPLIT_FROM_PARENT, StockHistoryCode.SPLIT_FROM_PARENT,
user, user,
quantity=quantity, quantity=quantity,
notes=notes, notes=kwargs.get('notes', ''),
location=location, location=location,
deltas=deltas, deltas=deltas,
) )
# Copy the test results of this part to the new one # Copy the test results of this part to the new one
new_stock.copyTestResultsFrom(self) if kwargs.get('copy_test_results', True):
new_stock.copyTestResultsFrom(self)
# Remove the specified quantity from THIS stock item # Remove the specified quantity from THIS stock item
self.take_stock( self.take_stock(
quantity, quantity,
user, user,
code=StockHistoryCode.SPLIT_CHILD_ITEM, code=StockHistoryCode.SPLIT_CHILD_ITEM,
notes=notes, notes=kwargs.get('notes', ''),
location=location, location=location,
stockitem=new_stock, stockitem=new_stock,
record_tracking=record_tracking, record_tracking=record_tracking,
@@ -2521,6 +2502,75 @@ class StockItem(
"""Returns a list of optional fields for a stock transfer.""" """Returns a list of optional fields for a stock transfer."""
return ['batch', 'status', 'packaging'] return ['batch', 'status', 'packaging']
@classmethod
def model_reference_fields(cls):
"""Returns a list of optional 'reference' fields for a stock adjustment.
If present, the referenced model is recorded (by pk) in the tracking deltas,
linking the resulting tracking entry back to the order which triggered it.
"""
return [
'stockitemlocation',
'transferorder',
'purchaseorder',
'salesorder',
'returnorder',
'buildorder',
]
def _apply_model_reference_fields(self, kwargs: dict, deltas: dict) -> None:
"""Pop any model reference kwargs (see model_reference_fields) and record their pk in deltas."""
for field in StockItem.model_reference_fields():
instance = deltas.pop(field, None) or kwargs.pop(field, None)
if instance is None:
continue
elif hasattr(instance, 'pk'):
deltas[field] = instance.pk
else:
deltas[field] = instance
def _resolve_status_kwarg(self, kwargs: dict):
"""Pop a status value from kwargs, preferring 'status' over 'status_custom_key'.
Returns:
The resolved status value, or None if neither key was provided (or truthy)
"""
return kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
def _apply_status_change(self, status, deltas: dict) -> None:
"""Apply a status change to this StockItem, recording delta information.
Args:
status: The new status value (may be a custom status key)
deltas: A dict to update with delta information (mutated in place)
"""
if not status or self.compare_status(status):
return
old_custom_status = self.get_custom_status()
old_status_logical = self.status
self.set_status(status)
deltas['status'] = status # may be a custom value
deltas['status_logical'] = self.status # always the logical value
deltas['old_status'] = (
old_custom_status if old_custom_status else old_status_logical
)
deltas['old_status_logical'] = old_status_logical
def _apply_optional_transfer_fields(self, kwargs: dict, deltas: dict) -> None:
"""Apply optional transfer fields (see optional_transfer_fields) from kwargs."""
for field in StockItem.optional_transfer_fields():
if field in kwargs:
setattr(self, field, kwargs[field])
# If the provided value is a model instance, store the primary key in the deltas dict
if hasattr(kwargs[field], 'pk'):
deltas[field] = kwargs[field].pk
else:
deltas[field] = kwargs[field]
@transaction.atomic @transaction.atomic
def move(self, location, notes, user, **kwargs): def move(self, location, notes, user, **kwargs):
"""Move part to a new location. """Move part to a new location.
@@ -2570,9 +2620,11 @@ class StockItem(
kwargs['notes'] = notes kwargs['notes'] = notes
# Split the existing StockItem in two # Split the existing StockItem in two
self.splitStock(quantity, location, user, allow_production=True, **kwargs) new_stock = self.splitStock(
quantity, location, user, allow_production=True, **kwargs
)
return True return new_stock is not None
# Moving into the same location triggers a different history code # Moving into the same location triggers a different history code
same_location = location == self.location same_location = location == self.location
@@ -2588,28 +2640,12 @@ class StockItem(
else: else:
tracking_info['location'] = location.pk tracking_info['location'] = location.pk
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) status = self._resolve_status_kwarg(kwargs)
self._apply_status_change(status, tracking_info)
if status and not self.compare_status(status): self._apply_model_reference_fields(kwargs, tracking_info)
old_custom_status = self.get_custom_status()
old_status_logical = self.status
self.set_status(status)
tracking_info['status'] = status # may be a custom value
tracking_info['status_logical'] = self.status # always the logical value
tracking_info['old_status'] = (
old_custom_status if old_custom_status else old_status_logical
)
tracking_info['old_status_logical'] = old_status_logical
transferorder = kwargs.pop('transferorder', None)
if transferorder:
tracking_info['transferorder'] = transferorder.pk
# Optional fields which can be supplied in a 'move' call # Optional fields which can be supplied in a 'move' call
for field in StockItem.optional_transfer_fields(): self._apply_optional_transfer_fields(kwargs, tracking_info)
if field in kwargs:
setattr(self, field, kwargs[field])
tracking_info[field] = kwargs[field]
self.add_tracking_entry(tracking_code, user, notes=notes, deltas=tracking_info) self.add_tracking_entry(tracking_code, user, notes=notes, deltas=tracking_info)
@@ -2635,13 +2671,14 @@ class StockItem(
Returns: Returns:
- True if the quantity was saved - True if the quantity was saved
- False if the StockItem was deleted - False if the StockItem was deleted
- None if the provided quantity was invalid, or the item is serialized
""" """
# Do not adjust quantity of a serialized part # Do not adjust quantity of a serialized part
if self.serialized: if self.serialized:
return return
try: try:
self.quantity = Decimal(quantity) quantity = Decimal(quantity)
except (InvalidOperation, ValueError): except (InvalidOperation, ValueError):
return return
@@ -2693,46 +2730,41 @@ class StockItem(
tracking_info['location'] = location.pk tracking_info['location'] = location.pk
tracking_info['old_location'] = old_location.pk if old_location else None tracking_info['old_location'] = old_location.pk if old_location else None
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) status = self._resolve_status_kwarg(kwargs)
self._apply_status_change(status, tracking_info)
self._apply_model_reference_fields(kwargs, tracking_info)
if status and not self.compare_status(status): quantity_updated = self.serialized or self.updateQuantity(count)
old_custom_status = self.get_custom_status()
old_status_logical = self.status
self.set_status(status)
tracking_info['status'] = status # may be a custom value
tracking_info['status_logical'] = self.status # always the logical value
tracking_info['old_status'] = (
old_custom_status if old_custom_status else old_status_logical
)
tracking_info['old_status_logical'] = old_status_logical
if self.serialized or self.updateQuantity(count): # Record the resulting quantity, whether or not this item survived the stocktake
tracking_info['quantity'] = 1 if self.serialized else float(count) # (self.quantity is updated by updateQuantity() even if the item was deleted)
tracking_info['quantity'] = 1 if self.serialized else float(self.quantity)
if quantity_updated:
self.stocktake_date = InvenTree.helpers.current_date() self.stocktake_date = InvenTree.helpers.current_date()
self.stocktake_user = user self.stocktake_user = user
# Optional fields which can be supplied in a 'stocktake' call # Optional fields which can be supplied in a 'stocktake' call
for field in StockItem.optional_transfer_fields(): self._apply_optional_transfer_fields(kwargs, tracking_info)
if field in kwargs:
setattr(self, field, kwargs[field])
tracking_info[field] = kwargs[field]
self.save(add_note=False) self.save(add_note=False)
self.add_tracking_entry(
StockHistoryCode.STOCK_COUNT,
user,
notes=kwargs.get('notes', ''),
deltas=tracking_info,
)
trigger_event( trigger_event(
StockEvents.ITEM_COUNTED, StockEvents.ITEM_COUNTED,
id=self.id, id=self.id,
quantity=1 if self.serialized else float(self.quantity), quantity=1 if self.serialized else float(self.quantity),
) )
# Always record a tracking entry, even if the item was deleted as a result
# of this stocktake (e.g. counted to zero with delete_on_deplete set) -
# StockItemTracking.item uses SET_NULL so the history is retained regardless.
self.add_tracking_entry(
StockHistoryCode.STOCK_COUNT,
user,
notes=kwargs.get('notes', ''),
deltas=tracking_info,
)
@transaction.atomic @transaction.atomic
def add_stock(self, quantity, user, **kwargs): def add_stock(self, quantity, user, **kwargs):
"""Add a specified quantity of stock to this item. """Add a specified quantity of stock to this item.
@@ -2760,28 +2792,16 @@ class StockItem(
tracking_info = {} tracking_info = {}
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) status = self._resolve_status_kwarg(kwargs)
self._apply_status_change(status, tracking_info)
if status and not self.compare_status(status): self._apply_model_reference_fields(kwargs, tracking_info)
old_custom_status = self.get_custom_status()
old_status_logical = self.status
self.set_status(status)
tracking_info['status'] = status # may be a custom value
tracking_info['status_logical'] = self.status # always the logical value
tracking_info['old_status'] = (
old_custom_status if old_custom_status else old_status_logical
)
tracking_info['old_status_logical'] = old_status_logical
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)
# Optional fields which can be supplied in a 'stocktake' call # Optional fields which can be supplied in a 'stocktake' call
for field in StockItem.optional_transfer_fields(): self._apply_optional_transfer_fields(kwargs, tracking_info)
if field in kwargs:
setattr(self, field, kwargs[field])
tracking_info[field] = kwargs[field]
self.save(add_note=False) self.save(add_note=False)
@@ -2824,45 +2844,30 @@ class StockItem(
deltas = {} deltas = {}
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) status = self._resolve_status_kwarg(kwargs)
self._apply_status_change(status, deltas)
self._apply_model_reference_fields(kwargs, deltas)
if status and not self.compare_status(status): quantity_updated = self.updateQuantity(self.quantity - quantity)
old_custom_status = self.get_custom_status()
old_status_logical = self.status
self.set_status(status)
deltas['status'] = status # may be a custom value
deltas['status_logical'] = self.status # always the logical value
deltas['old_status'] = (
old_custom_status if old_custom_status else old_status_logical
)
deltas['old_status_logical'] = old_status_logical
if self.updateQuantity(self.quantity - quantity): # Record the resulting quantity, whether or not this item survived the removal
deltas['removed'] = float(quantity) # (self.quantity is updated by updateQuantity() even if the item was deleted)
deltas['quantity'] = float(self.quantity) deltas['removed'] = float(quantity)
deltas['quantity'] = float(self.quantity)
if location := kwargs.get('location'):
deltas['location'] = location.pk
if stockitem := kwargs.get('stockitem'):
deltas['stockitem'] = stockitem.pk
if quantity_updated:
# Optional fields which can be supplied in a 'stocktake' call # Optional fields which can be supplied in a 'stocktake' call
for field in StockItem.optional_transfer_fields(): self._apply_optional_transfer_fields(kwargs, deltas)
if field in kwargs:
setattr(self, field, kwargs[field])
deltas[field] = kwargs[field]
transferorder = kwargs.pop('transferorder', None)
if transferorder:
deltas['transferorder'] = transferorder.pk
self.save(add_note=False) self.save(add_note=False)
if record_tracking: # Always record a tracking entry, even if the item was deleted as a result
self.add_tracking_entry( # of this removal (e.g. depleted to zero with delete_on_deplete set) -
code, user, notes=kwargs.get('notes', ''), deltas=deltas # StockItemTracking.item uses SET_NULL so the history is retained regardless.
) if record_tracking:
self.add_tracking_entry(
code, user, notes=kwargs.get('notes', ''), deltas=deltas
)
return True return True
@@ -3033,18 +3038,20 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
"""Hook function to be executed after StockItem object is saved/updated.""" """Hook function to be executed after StockItem object is saved/updated."""
from part import tasks as part_tasks from part import tasks as part_tasks
if not InvenTree.ready.isImportingData(): if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
if InvenTree.ready.canAppAccessDatabase(allow_test=True): return
InvenTree.tasks.offload_task(
part_tasks.notify_low_stock_if_required,
instance.part.pk,
group='notification',
force_async=True,
)
if InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING): if InvenTree.ready.canAppAccessDatabase(allow_test=True):
if instance.part: InvenTree.tasks.offload_task(
instance.part.schedule_pricing_update(create=True) part_tasks.notify_low_stock_if_required,
instance.part.pk,
group='notification',
force_async=True,
)
if InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING):
if instance.part:
instance.part.schedule_pricing_update(create=True)
class StockItemTracking(InvenTree.models.InvenTreeModel): class StockItemTracking(InvenTree.models.InvenTreeModel):
@@ -1948,6 +1948,10 @@ class StockTransferSerializer(StockAdjustmentSerializer):
split_transfer_deltas=transfer_deltas, split_transfer_deltas=transfer_deltas,
**kwargs, **kwargs,
) )
if not piece:
continue
merge_kwargs['transfer_deltas'] = transfer_deltas merge_kwargs['transfer_deltas'] = transfer_deltas
target.merge_stock_items([piece], **merge_kwargs) target.merge_stock_items([piece], **merge_kwargs)
else: else: