diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5aeaa4e535..061c60ea48 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -9,7 +9,7 @@ INVENTREE_API_TEXT = """ v498 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12022 - Adds optional "merge" field to each item in the Stock Transfer API endpoint - When merge is enabled, transferred stock is combined into compatible existing stock at the destination - - Stock merge tracking entries now include an "added" delta field; + - Stock merge tracking entries now include an "added" delta field v497 -> 2026-05-27 : https://github.com/inventree/InvenTree/pull/12019 - Adds "location" field to StockCount API endpoint diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 559a9c90aa..0834e2c67c 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2307,25 +2307,27 @@ class StockItem( self.parent = None self.save() - if other.location: - location_note = _('Transferred from %(location)s') % { - 'location': other.location.pathstring - } - - notes = f'{notes}\n{location_note}' if notes else location_note - other.delete() + transfer_deltas = kwargs.pop('transfer_deltas', None) + + tracking_deltas = { + 'quantity': float(self.quantity), + 'added': float(merged_quantity), + } + + if location: + tracking_deltas['location'] = location.pk + + if transfer_deltas: + tracking_deltas = {**transfer_deltas, **tracking_deltas} + self.add_tracking_entry( StockHistoryCode.MERGED_STOCK_ITEMS, user, quantity=self.quantity, notes=notes, - deltas={ - 'location': location.pk if location else None, - 'quantity': float(self.quantity), - 'added': float(merged_quantity), - }, + deltas=tracking_deltas, ) # Update the location of the item @@ -2386,6 +2388,8 @@ class StockItem( status: If provided, override the status (default = existing status) packaging: If provided, override the packaging (default = existing packaging) 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) + split_transfer_deltas: Optional dict to receive split tracking deltas Returns: The new StockItem object @@ -2398,6 +2402,8 @@ class StockItem( """ # Run initial checks to test if the stock item can actually be "split" allow_production = kwargs.get('allow_production', False) + record_tracking = kwargs.pop('record_tracking', True) + split_transfer_deltas = kwargs.pop('split_transfer_deltas', None) # Cannot split a stock item which is in production if self.is_building and not allow_production: @@ -2470,15 +2476,23 @@ class StockItem( new_stock.save(add_note=False) - # Add a stock tracking entry for the newly created item - new_stock.add_tracking_entry( - StockHistoryCode.SPLIT_FROM_PARENT, - user, - quantity=quantity, - notes=notes, - location=location, - deltas=deltas, - ) + if split_transfer_deltas is not None: + split_transfer_deltas.clear() + split_transfer_deltas.update(deltas) + + if location: + split_transfer_deltas['location'] = location.pk + + if record_tracking: + # Add a stock tracking entry for the newly created item + new_stock.add_tracking_entry( + StockHistoryCode.SPLIT_FROM_PARENT, + user, + quantity=quantity, + notes=notes, + location=location, + deltas=deltas, + ) # Copy the test results of this part to the new one new_stock.copyTestResultsFrom(self) @@ -2491,6 +2505,7 @@ class StockItem( notes=notes, location=location, stockitem=new_stock, + record_tracking=record_tracking, ) # Rebuild the tree for this parent item @@ -2800,7 +2815,10 @@ class StockItem( code: The stock history code to use notes: Optional notes for the stock removal status: Optionally adjust the stock status + record_tracking: If False, skip creating a tracking entry """ + record_tracking = kwargs.pop('record_tracking', True) + # Cannot remove items from a serialized part if self.serialized: return False @@ -2850,9 +2868,10 @@ class StockItem( self.save(add_note=False) - self.add_tracking_entry( - code, user, notes=kwargs.get('notes', ''), deltas=deltas - ) + if record_tracking: + self.add_tracking_entry( + code, user, notes=kwargs.get('notes', ''), deltas=deltas + ) return True diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index dc16476d15..ea558c7a4c 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1907,16 +1907,31 @@ class StockTransferSerializer(StockAdjustmentSerializer): } if quantity < stock_item.quantity: + transfer_deltas = {} + piece = stock_item.splitStock( quantity, location, request.user, notes=notes, allow_production=True, + record_tracking=False, + split_transfer_deltas=transfer_deltas, **kwargs, ) + merge_kwargs['transfer_deltas'] = transfer_deltas target.merge_stock_items([piece], **merge_kwargs) else: + transfer_deltas = {'stockitem': stock_item.pk} + + if location: + transfer_deltas['location'] = location.pk + + for field_name in StockItem.optional_transfer_fields(): + if field_name in kwargs: + transfer_deltas[field_name] = kwargs[field_name] + + merge_kwargs['transfer_deltas'] = transfer_deltas target.merge_stock_items([stock_item], **merge_kwargs) continue diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 7b7316cc17..ebe1a0e4bd 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2448,6 +2448,7 @@ class StockTransferMergeTest(StockAPITestCase): StockHistoryCode.STOCK_UPDATE, self.user, notes='Source tracking entry' ) + incoming_pk = incoming.pk tracking_count = existing.tracking_info.count() self.post( @@ -2471,6 +2472,40 @@ class StockTransferMergeTest(StockAPITestCase): self.assertIsNotNone(merge_entry) self.assertEqual(merge_entry.deltas['added'], 50.0) self.assertEqual(merge_entry.deltas['quantity'], 150.0) + self.assertEqual(merge_entry.deltas['stockitem'], incoming_pk) + self.assertEqual(merge_entry.deltas['location'], self.dest.pk) + + def test_transfer_merge_partial_reuses_split_transfer_deltas(self): + """Partial merge reuses split transfer deltas on the merge tracking entry.""" + existing = StockItem.objects.create( + part=self.part, location=self.dest, quantity=100 + ) + incoming = StockItem.objects.create( + part=self.part, location=self.source_loc, quantity=100 + ) + + self.post( + self.url, + { + 'items': [{'pk': incoming.pk, 'quantity': 30, 'merge': True}], + 'location': self.dest.pk, + }, + expected_code=201, + ) + + incoming.refresh_from_db() + self.assertEqual(incoming.quantity, 70) + + merge_entry = existing.tracking_info.filter( + tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS + ).first() + self.assertEqual(merge_entry.deltas['stockitem'], incoming.pk) + self.assertEqual(merge_entry.deltas['location'], self.dest.pk) + self.assertFalse( + incoming.tracking_info.filter( + tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM + ).exists() + ) class StockItemDeletionTest(StockAPITestCase):