From 1007913a930c9ddb2106e1b36211a695a57a4ba0 Mon Sep 17 00:00:00 2001 From: Neil Beukes Date: Tue, 26 May 2026 15:10:32 +0000 Subject: [PATCH 01/10] working on merge transfer --- .../InvenTree/common/setting/system.py | 9 ++ src/backend/InvenTree/stock/models.py | 44 +++++++- src/backend/InvenTree/stock/serializers.py | 40 ++++++- src/backend/InvenTree/stock/test_api.py | 100 ++++++++++++++++++ src/backend/InvenTree/stock/tests.py | 12 +++ src/frontend/src/forms/StockForms.tsx | 23 +++- .../pages/Index/Settings/SystemSettings.tsx | 1 + src/frontend/vite.config.ts | 1 + 8 files changed, 225 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index c646aa843d0c..ba9e60e87c97 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -787,6 +787,15 @@ class SystemSetId: 'default': False, 'validator': bool, }, + 'STOCK_MERGE_ON_TRANSFER': { + 'name': _('Merge stock with existing stock on transfer'), + 'description': _( + 'Default: when transferring stock, merge into an existing lot at the ' + 'destination if possible (uses the same rules as manual stock merge)' + ), + 'default': False, + 'validator': bool, + }, 'BUILDORDER_REFERENCE_PATTERN': { 'name': _('Build Order Reference Pattern'), 'description': _('Required pattern for generating Build Order reference field'), diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 040daee86ef3..d3274e6a47d9 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2179,6 +2179,34 @@ def can_merge(self, other=None, raise_error=False, **kwargs): return True + def find_merge_target(self, location): + """Find an existing stock item at *location* that can absorb this item.""" + if location is None: + return None + + candidates = list( + StockItem.objects.filter(part=self.part, location=location) + .exclude(pk=self.pk) + .order_by('pk') + ) + + if not candidates: + return None + + if self.batch: + batch_matches = [c for c in candidates if c.batch == self.batch] + search_order = batch_matches + [ + c for c in candidates if c not in batch_matches + ] + else: + search_order = candidates + + for target in search_order: + if target.can_merge(other=self, raise_error=False): + return target + + return None + @transaction.atomic def merge_stock_items(self, other_items, raise_error=False, **kwargs): """Merge another stock item into this one; the two become one! @@ -2186,7 +2214,7 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): *This* stock item subsumes the other, which is essentially deleted: - The quantity of this StockItem is increased - - Tracking history for the *other* item is deleted + - Tracking history for the *other* item is copied to this item - Any allocations (build order, sales order) are moved to this StockItem """ if isinstance(other_items, StockItem): @@ -2200,7 +2228,7 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): user = kwargs.get('user') location = kwargs.get('location', self.location) - notes = kwargs.get('notes') + notes = kwargs.get('notes') or '' parent_id = self.parent.pk if self.parent else None @@ -2242,6 +2270,18 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): self.parent = None self.save() + self.copyHistoryFrom(other) + + if other.location: + location_note = _('Transferred from %(location)s') % { + 'location': other.location.pathstring + } + + if notes: + notes = f'{notes}\n{location_note}' + else: + notes = location_note + other.delete() self.add_tracking_entry( diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 570b4f7e518e..6b857ac4033c 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1829,7 +1829,7 @@ class StockTransferSerializer(StockAdjustmentSerializer): class Meta: """Metaclass options.""" - fields = ['items', 'notes', 'location'] + fields = ['items', 'notes', 'location', 'merge'] items = StockAdjustmentItemSerializer(many=True, require_non_zero=True) @@ -1842,6 +1842,15 @@ class Meta: help_text=_('Destination stock location'), ) + merge = serializers.BooleanField( + default=False, + required=False, + label=_('Merge into existing stock'), + help_text=_( + 'Merge transferred items into existing stock items at the destination if possible' + ), + ) + def save(self): """Transfer stock.""" request = self.context['request'] @@ -1851,6 +1860,7 @@ def save(self): items = data['items'] notes = data.get('notes', '') location = data['location'] + merge = data.get('merge', False) with transaction.atomic(): for item in items: @@ -1865,6 +1875,34 @@ def save(self): if field_value := item.get(field_name, None): kwargs[field_name] = field_value + if merge: + target = stock_item.find_merge_target(location) + + if target: + merge_kwargs = { + 'location': location, + 'notes': notes, + 'user': request.user, + **kwargs, + } + + if quantity < stock_item.quantity: + piece = stock_item.splitStock( + quantity, + location, + request.user, + notes=notes, + allow_production=True, + **kwargs, + ) + target.merge_stock_items([piece], **merge_kwargs) + else: + target.merge_stock_items( + [stock_item], **merge_kwargs + ) + + continue + stock_item.move( location, notes, request.user, quantity=quantity, **kwargs ) diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index d274fffafa9d..a4961d829d87 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2154,6 +2154,106 @@ def test_transfer(self): ) +class StockTransferMergeTest(StockAPITestCase): + """Tests for optional merge-on-transfer behavior.""" + + def setUp(self): + """Set up stock items for merge transfer tests.""" + super().setUp() + + self.part = Part.objects.get(pk=1) + self.dest = StockLocation.objects.get(pk=2) + self.source_loc = StockLocation.objects.get(pk=5) + self.url = reverse('api-stock-transfer') + + def test_transfer_without_merge_creates_separate_lot(self): + """Transfer without merge leaves multiple stock rows at destination.""" + existing = StockItem.objects.create( + part=self.part, location=self.dest, quantity=100 + ) + incoming = StockItem.objects.create( + part=self.part, location=self.source_loc, quantity=50 + ) + + self.post( + self.url, + { + 'items': [{'pk': incoming.pk, 'quantity': 50}], + 'location': self.dest.pk, + 'merge': False, + }, + expected_code=201, + ) + + self.assertEqual( + StockItem.objects.filter(part=self.part, location=self.dest).count(), 2 + ) + + existing.refresh_from_db() + self.assertEqual(existing.quantity, 100) + + def test_transfer_with_merge_combines_lots(self): + """Transfer with merge combines into an existing compatible lot.""" + existing = StockItem.objects.create( + part=self.part, location=self.dest, quantity=100 + ) + incoming = StockItem.objects.create( + part=self.part, location=self.source_loc, quantity=50 + ) + + self.post( + self.url, + { + 'items': [{'pk': incoming.pk, 'quantity': 50}], + 'location': self.dest.pk, + 'merge': True, + }, + expected_code=201, + ) + + self.assertEqual( + StockItem.objects.filter(part=self.part, location=self.dest).count(), 1 + ) + + existing.refresh_from_db() + self.assertEqual(existing.quantity, 150) + self.assertFalse(StockItem.objects.filter(pk=incoming.pk).exists()) + + def test_transfer_merge_preserves_tracking(self): + """Transfer merge copies tracking history onto the surviving stock item.""" + existing = StockItem.objects.create( + part=self.part, location=self.dest, quantity=100 + ) + incoming = StockItem.objects.create( + part=self.part, location=self.source_loc, quantity=50 + ) + + incoming.add_tracking_entry( + StockHistoryCode.STOCK_UPDATE, + self.user, + notes='Source tracking entry', + ) + + tracking_count = existing.tracking_info.count() + + self.post( + self.url, + { + 'items': [{'pk': incoming.pk, 'quantity': 50}], + 'location': self.dest.pk, + 'merge': True, + }, + expected_code=201, + ) + + existing.refresh_from_db() + + self.assertTrue( + existing.tracking_info.filter(notes='Source tracking entry').exists() + ) + self.assertGreater(existing.tracking_info.count(), tracking_count) + + class StockItemDeletionTest(StockAPITestCase): """Tests for stock item deletion via the API.""" diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index 94954323667e..be3c683dc368 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -722,12 +722,24 @@ def test_merge(self): s2 = StockItem.objects.create(part=part, quantity=20) s3 = StockItem.objects.create(part=part, quantity=30) + s2.add_tracking_entry( + StockHistoryCode.STOCK_UPDATE, + None, + notes='Merged away tracking', + ) + + tracking_before = s1.tracking_info.count() + self.assertEqual(part.stock_items.count(), 3) s1.merge_stock_items([s2, s3]) self.assertEqual(part.stock_items.count(), 1) s1.refresh_from_db() self.assertEqual(s1.quantity, 60) self.assertIsNone(s1.purchase_price) + self.assertTrue( + s1.tracking_info.filter(notes='Merged away tracking').exists() + ) + self.assertGreater(s1.tracking_info.count(), tracking_before) part.stock_items.all().delete() diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 547d2b208778..790f74d6edf2 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -20,7 +20,14 @@ import { IconUsersGroup } from '@tabler/icons-react'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; -import { type JSX, Suspense, useEffect, useMemo, useState } from 'react'; +import { + type JSX, + Suspense, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; import { ActionButton } from '@lib/components/ActionButton'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; @@ -1319,9 +1326,21 @@ export function useRemoveStockItem(props: StockOperationProps) { } export function useTransferStockItem(props: StockOperationProps) { + const globalSettings = useGlobalSettingsState(); + + const fieldGenerator = useCallback( + (items: any[]) => ({ + ...stockTransferFields(items), + merge: { + default: globalSettings.isSet('STOCK_MERGE_ON_TRANSFER') + } + }), + [globalSettings] + ); + return useStockOperationModal({ ...props, - fieldGenerator: stockTransferFields, + fieldGenerator: fieldGenerator, endpoint: ApiEndpoints.stock_transfer, title: t`Transfer Stock`, successMessage: t`Stock transferred`, diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 0078520bd943..c7a090a69d5f 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -259,6 +259,7 @@ export default function SystemSettings() { 'STOCK_SHOW_INSTALLED_ITEMS', 'STOCK_ENFORCE_BOM_INSTALLATION', 'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', + 'STOCK_MERGE_ON_TRANSFER', 'TEST_STATION_DATA' ]} /> diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 627a1ade6e1b..fdbebca9972d 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -82,6 +82,7 @@ export default defineConfig(({ command, mode }) => { changeOrigin: true, secure: true } + }, watch: { // Use polling only for WSL as the file system doesn't trigger notifications for Linux apps From 9f01acaa66ff86723d966ad277425ed2c1ec44ae Mon Sep 17 00:00:00 2001 From: Neil Beukes Date: Tue, 26 May 2026 17:30:17 +0000 Subject: [PATCH 02/10] fix history when merging --- src/backend/InvenTree/stock/models.py | 17 ++++++++++------- src/backend/InvenTree/stock/serializers.py | 5 ++--- src/backend/InvenTree/stock/test_api.py | 20 +++++++++++++------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index d3274e6a47d9..42eff73c0167 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2185,7 +2185,8 @@ def find_merge_target(self, location): return None candidates = list( - StockItem.objects.filter(part=self.part, location=location) + StockItem.objects + .filter(part=self.part, location=location) .exclude(pk=self.pk) .order_by('pk') ) @@ -2214,8 +2215,11 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): *This* stock item subsumes the other, which is essentially deleted: - The quantity of this StockItem is increased - - Tracking history for the *other* item is copied to this item + - Tracking history for the *other* item is copied to this item (unless copy_history=False) - Any allocations (build order, sales order) are moved to this StockItem + + kwargs: + copy_history: If True (default), copy tracking from merged items. Set False for merge-on-transfer. """ if isinstance(other_items, StockItem): other_items = [other_items] @@ -2229,6 +2233,7 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): user = kwargs.get('user') location = kwargs.get('location', self.location) notes = kwargs.get('notes') or '' + copy_history = kwargs.pop('copy_history', True) parent_id = self.parent.pk if self.parent else None @@ -2270,17 +2275,15 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): self.parent = None self.save() - self.copyHistoryFrom(other) + if copy_history: + self.copyHistoryFrom(other) if other.location: location_note = _('Transferred from %(location)s') % { 'location': other.location.pathstring } - if notes: - notes = f'{notes}\n{location_note}' - else: - notes = location_note + notes = f'{notes}\n{location_note}' if notes else location_note other.delete() diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 6b857ac4033c..fe43a8047c72 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1883,6 +1883,7 @@ def save(self): 'location': location, 'notes': notes, 'user': request.user, + 'copy_history': False, **kwargs, } @@ -1897,9 +1898,7 @@ def save(self): ) target.merge_stock_items([piece], **merge_kwargs) else: - target.merge_stock_items( - [stock_item], **merge_kwargs - ) + 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 a4961d829d87..67eb591c12ff 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2166,6 +2166,9 @@ def setUp(self): self.source_loc = StockLocation.objects.get(pk=5) self.url = reverse('api-stock-transfer') + # Remove fixture stock at the destination so merge targets are deterministic + StockItem.objects.filter(part=self.part, location=self.dest).delete() + def test_transfer_without_merge_creates_separate_lot(self): """Transfer without merge leaves multiple stock rows at destination.""" existing = StockItem.objects.create( @@ -2219,8 +2222,8 @@ def test_transfer_with_merge_combines_lots(self): self.assertEqual(existing.quantity, 150) self.assertFalse(StockItem.objects.filter(pk=incoming.pk).exists()) - def test_transfer_merge_preserves_tracking(self): - """Transfer merge copies tracking history onto the surviving stock item.""" + def test_transfer_merge_does_not_copy_source_tracking(self): + """Transfer merge keeps destination history and adds a single merge entry.""" existing = StockItem.objects.create( part=self.part, location=self.dest, quantity=100 ) @@ -2229,9 +2232,7 @@ def test_transfer_merge_preserves_tracking(self): ) incoming.add_tracking_entry( - StockHistoryCode.STOCK_UPDATE, - self.user, - notes='Source tracking entry', + StockHistoryCode.STOCK_UPDATE, self.user, notes='Source tracking entry' ) tracking_count = existing.tracking_info.count() @@ -2248,10 +2249,15 @@ def test_transfer_merge_preserves_tracking(self): existing.refresh_from_db() - self.assertTrue( + self.assertFalse( existing.tracking_info.filter(notes='Source tracking entry').exists() ) - self.assertGreater(existing.tracking_info.count(), tracking_count) + self.assertEqual(existing.tracking_info.count(), tracking_count + 1) + self.assertTrue( + existing.tracking_info.filter( + tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS + ).exists() + ) class StockItemDeletionTest(StockAPITestCase): From 47ed85e180965cb6cb004fa5e73f7f442a50f24a Mon Sep 17 00:00:00 2001 From: Neil Beukes Date: Wed, 27 May 2026 08:00:28 +0000 Subject: [PATCH 03/10] update messaging, add 'added' line in history --- .../InvenTree/common/setting/system.py | 5 +- src/backend/InvenTree/stock/models.py | 6 ++- src/backend/InvenTree/stock/serializers.py | 14 ++++- src/backend/InvenTree/stock/test_api.py | 53 +++++++++++++++---- src/backend/InvenTree/stock/tests.py | 15 +++--- src/frontend/src/forms/StockForms.tsx | 36 +++++++++---- 6 files changed, 97 insertions(+), 32 deletions(-) diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index ba9e60e87c97..ebe15df8ec97 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -788,10 +788,9 @@ class SystemSetId: 'validator': bool, }, 'STOCK_MERGE_ON_TRANSFER': { - 'name': _('Merge stock with existing stock on transfer'), + 'name': _('Merge stock with existing stock on transfer by default'), 'description': _( - 'Default: when transferring stock, merge into an existing lot at the ' - 'destination if possible (uses the same rules as manual stock merge)' + 'Default state for merge stock on transfer behaviour. (Can be changed per transfer if desired)' ), 'default': False, 'validator': bool, diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 42eff73c0167..8a12bde90d3f 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2251,9 +2251,12 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): ) return + merged_quantity = Decimal(0) + for other in other_items: tree_ids.add(other.tree_id) + merged_quantity += other.quantity self.quantity += other.quantity if other.purchase_price: @@ -2294,7 +2297,8 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): notes=notes, deltas={ 'location': location.pk if location else None, - 'quantity': self.quantity, + 'quantity': float(self.quantity), + 'added': float(merged_quantity), }, ) diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index fe43a8047c72..7ca257cfcbd2 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1635,7 +1635,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = ['pk', 'quantity', 'batch', 'status', 'packaging'] + fields = ['pk', 'quantity', 'batch', 'status', 'packaging', 'merge'] def __init__(self, *args, **kwargs): """Initialize the serializer.""" @@ -1709,6 +1709,15 @@ def validate_quantity(self, quantity): help_text=_('Packaging this stock item is stored in'), ) + merge = serializers.BooleanField( + default=False, + required=False, + label=_('Merge into existing stock'), + help_text=_( + 'Merge this item into existing stock at the destination if possible' + ), + ) + class StockAdjustmentSerializer(serializers.Serializer): """Base class for managing stock adjustment actions via the API.""" @@ -1860,13 +1869,14 @@ def save(self): items = data['items'] notes = data.get('notes', '') location = data['location'] - merge = data.get('merge', False) + default_merge = data.get('merge', False) with transaction.atomic(): for item in items: # Required fields stock_item = item['pk'] quantity = item['quantity'] + merge = item.get('merge', default_merge) # Optional fields kwargs = {} diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 67eb591c12ff..2ffbbcd83726 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2181,9 +2181,8 @@ def test_transfer_without_merge_creates_separate_lot(self): self.post( self.url, { - 'items': [{'pk': incoming.pk, 'quantity': 50}], + 'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': False}], 'location': self.dest.pk, - 'merge': False, }, expected_code=201, ) @@ -2207,9 +2206,8 @@ def test_transfer_with_merge_combines_lots(self): self.post( self.url, { - 'items': [{'pk': incoming.pk, 'quantity': 50}], + 'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': True}], 'location': self.dest.pk, - 'merge': True, }, expected_code=201, ) @@ -2222,6 +2220,39 @@ def test_transfer_with_merge_combines_lots(self): self.assertEqual(existing.quantity, 150) self.assertFalse(StockItem.objects.filter(pk=incoming.pk).exists()) + def test_transfer_mixed_merge_per_item(self): + """Each transfer line can merge or move independently.""" + existing = StockItem.objects.create( + part=self.part, location=self.dest, quantity=100 + ) + merge_incoming = StockItem.objects.create( + part=self.part, location=self.source_loc, quantity=30 + ) + separate_incoming = StockItem.objects.create( + part=self.part, location=self.source_loc, quantity=20 + ) + + self.post( + self.url, + { + 'items': [ + {'pk': merge_incoming.pk, 'quantity': 30, 'merge': True}, + {'pk': separate_incoming.pk, 'quantity': 20, 'merge': False}, + ], + 'location': self.dest.pk, + }, + expected_code=201, + ) + + self.assertEqual( + StockItem.objects.filter(part=self.part, location=self.dest).count(), 2 + ) + + existing.refresh_from_db() + self.assertEqual(existing.quantity, 130) + self.assertFalse(StockItem.objects.filter(pk=merge_incoming.pk).exists()) + self.assertTrue(StockItem.objects.filter(pk=separate_incoming.pk).exists()) + def test_transfer_merge_does_not_copy_source_tracking(self): """Transfer merge keeps destination history and adds a single merge entry.""" existing = StockItem.objects.create( @@ -2240,9 +2271,8 @@ def test_transfer_merge_does_not_copy_source_tracking(self): self.post( self.url, { - 'items': [{'pk': incoming.pk, 'quantity': 50}], + 'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': True}], 'location': self.dest.pk, - 'merge': True, }, expected_code=201, ) @@ -2253,11 +2283,12 @@ def test_transfer_merge_does_not_copy_source_tracking(self): existing.tracking_info.filter(notes='Source tracking entry').exists() ) self.assertEqual(existing.tracking_info.count(), tracking_count + 1) - self.assertTrue( - existing.tracking_info.filter( - tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS - ).exists() - ) + merge_entry = existing.tracking_info.filter( + tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS + ).first() + self.assertIsNotNone(merge_entry) + self.assertEqual(merge_entry.deltas['added'], 50.0) + self.assertEqual(merge_entry.deltas['quantity'], 150.0) class StockItemDeletionTest(StockAPITestCase): diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index be3c683dc368..aa453025f437 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -723,9 +723,7 @@ def test_merge(self): s3 = StockItem.objects.create(part=part, quantity=30) s2.add_tracking_entry( - StockHistoryCode.STOCK_UPDATE, - None, - notes='Merged away tracking', + StockHistoryCode.STOCK_UPDATE, None, notes='Merged away tracking' ) tracking_before = s1.tracking_info.count() @@ -736,11 +734,16 @@ def test_merge(self): s1.refresh_from_db() self.assertEqual(s1.quantity, 60) self.assertIsNone(s1.purchase_price) - self.assertTrue( - s1.tracking_info.filter(notes='Merged away tracking').exists() - ) + self.assertTrue(s1.tracking_info.filter(notes='Merged away tracking').exists()) self.assertGreater(s1.tracking_info.count(), tracking_before) + merge_entry = s1.tracking_info.filter( + tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS + ).first() + self.assertIsNotNone(merge_entry) + self.assertEqual(merge_entry.deltas['added'], 50.0) + self.assertEqual(merge_entry.deltas['quantity'], 60.0) + part.stock_items.all().delete() # Create some stock items with pricing information diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 790f74d6edf2..5ae4fd3cbd07 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -555,6 +555,7 @@ function StockOperationsRow({ add = false, setMax = false, merge = false, + transferMerge = false, record }: { props: TableFieldRowProps; @@ -563,6 +564,7 @@ function StockOperationsRow({ add?: boolean; setMax?: boolean; merge?: boolean; + transferMerge?: boolean; record?: any; }) { const statusOptions: ApiFormFieldChoice[] = useMemo(() => { @@ -712,6 +714,17 @@ function StockOperationsRow({ variant={packagingOpen ? 'filled' : 'transparent'} /> )} + {transferMerge && ( + } + tooltip={t`Merge into existing stock`} + onClick={() => + callChangeFn(props.idx, 'merge', !props.item?.merge) + } + variant={props.item?.merge ? 'filled' : 'transparent'} + /> + )} props.removeFn(props.idx)} /> @@ -759,9 +772,10 @@ type StockAdjustmentItem = { batch?: string; status?: number | '' | null; packaging?: string; + merge?: boolean; }; -function mapAdjustmentItems(items: any[]) { +function mapAdjustmentItems(items: any[], mergeDefault?: boolean) { const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => { return { pk: elem.pk, @@ -769,6 +783,7 @@ function mapAdjustmentItems(items: any[]) { batch: elem.batch || undefined, status: elem.status || undefined, packaging: elem.packaging || undefined, + merge: elem.merge ?? mergeDefault ?? false, obj: elem }; }); @@ -776,7 +791,10 @@ function mapAdjustmentItems(items: any[]) { return mappedItems; } -function stockTransferFields(items: any[]): ApiFormFieldSet { +function stockTransferFields( + items: any[], + mergeDefault = false +): ApiFormFieldSet { if (!items) { return {}; } @@ -789,7 +807,7 @@ function stockTransferFields(items: any[]): ApiFormFieldSet { const fields: ApiFormFieldSet = { items: { field_type: 'table', - value: mapAdjustmentItems(items), + value: mapAdjustmentItems(items, mergeDefault), modelRenderer: (row: TableFieldRowProps) => { const record = records[row.item.pk]; @@ -799,6 +817,7 @@ function stockTransferFields(items: any[]): ApiFormFieldSet { transfer changeStatus setMax + transferMerge key={record.pk} record={record} /> @@ -1329,12 +1348,11 @@ export function useTransferStockItem(props: StockOperationProps) { const globalSettings = useGlobalSettingsState(); const fieldGenerator = useCallback( - (items: any[]) => ({ - ...stockTransferFields(items), - merge: { - default: globalSettings.isSet('STOCK_MERGE_ON_TRANSFER') - } - }), + (items: any[]) => + stockTransferFields( + items, + globalSettings.isSet('STOCK_MERGE_ON_TRANSFER') + ), [globalSettings] ); From f261bf739016fb84737ebb8b2cdf8d65c20ecc6f Mon Sep 17 00:00:00 2001 From: Neil Beukes Date: Wed, 27 May 2026 13:37:14 +0000 Subject: [PATCH 04/10] reworked history logic --- src/backend/InvenTree/stock/models.py | 11 ++--------- src/backend/InvenTree/stock/serializers.py | 1 - src/backend/InvenTree/stock/tests.py | 8 -------- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 8a12bde90d3f..f9b943969d24 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2180,7 +2180,7 @@ def can_merge(self, other=None, raise_error=False, **kwargs): return True def find_merge_target(self, location): - """Find an existing stock item at *location* that can absorb this item.""" + """Find an existing stock item at location that can absorb this item.""" if location is None: return None @@ -2215,11 +2215,8 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): *This* stock item subsumes the other, which is essentially deleted: - The quantity of this StockItem is increased - - Tracking history for the *other* item is copied to this item (unless copy_history=False) + - Tracking history for the *other* item is deleted - Any allocations (build order, sales order) are moved to this StockItem - - kwargs: - copy_history: If True (default), copy tracking from merged items. Set False for merge-on-transfer. """ if isinstance(other_items, StockItem): other_items = [other_items] @@ -2233,7 +2230,6 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): user = kwargs.get('user') location = kwargs.get('location', self.location) notes = kwargs.get('notes') or '' - copy_history = kwargs.pop('copy_history', True) parent_id = self.parent.pk if self.parent else None @@ -2278,9 +2274,6 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): self.parent = None self.save() - if copy_history: - self.copyHistoryFrom(other) - if other.location: location_note = _('Transferred from %(location)s') % { 'location': other.location.pathstring diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 7ca257cfcbd2..231fbaa29299 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1893,7 +1893,6 @@ def save(self): 'location': location, 'notes': notes, 'user': request.user, - 'copy_history': False, **kwargs, } diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index aa453025f437..999fc030b0d2 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -722,20 +722,12 @@ def test_merge(self): s2 = StockItem.objects.create(part=part, quantity=20) s3 = StockItem.objects.create(part=part, quantity=30) - s2.add_tracking_entry( - StockHistoryCode.STOCK_UPDATE, None, notes='Merged away tracking' - ) - - tracking_before = s1.tracking_info.count() - self.assertEqual(part.stock_items.count(), 3) s1.merge_stock_items([s2, s3]) self.assertEqual(part.stock_items.count(), 1) s1.refresh_from_db() self.assertEqual(s1.quantity, 60) self.assertIsNone(s1.purchase_price) - self.assertTrue(s1.tracking_info.filter(notes='Merged away tracking').exists()) - self.assertGreater(s1.tracking_info.count(), tracking_before) merge_entry = s1.tracking_info.filter( tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS From b5fbe784b3134931d6dc881f68fd7d356946cd02 Mon Sep 17 00:00:00 2001 From: Neil Beukes Date: Wed, 27 May 2026 13:48:22 +0000 Subject: [PATCH 05/10] removed old transfer logic --- src/backend/InvenTree/stock/serializers.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 231fbaa29299..8166ecfc8796 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1838,7 +1838,7 @@ class StockTransferSerializer(StockAdjustmentSerializer): class Meta: """Metaclass options.""" - fields = ['items', 'notes', 'location', 'merge'] + fields = ['items', 'notes', 'location'] items = StockAdjustmentItemSerializer(many=True, require_non_zero=True) @@ -1851,15 +1851,6 @@ class Meta: help_text=_('Destination stock location'), ) - merge = serializers.BooleanField( - default=False, - required=False, - label=_('Merge into existing stock'), - help_text=_( - 'Merge transferred items into existing stock items at the destination if possible' - ), - ) - def save(self): """Transfer stock.""" request = self.context['request'] @@ -1869,14 +1860,13 @@ def save(self): items = data['items'] notes = data.get('notes', '') location = data['location'] - default_merge = data.get('merge', False) with transaction.atomic(): for item in items: # Required fields stock_item = item['pk'] quantity = item['quantity'] - merge = item.get('merge', default_merge) + merge = item.get('merge', False) # Optional fields kwargs = {} From 3513a6d97d4b89e008b4f0fd643ff9c64e09e265 Mon Sep 17 00:00:00 2001 From: Neil Beukes Date: Wed, 27 May 2026 14:09:41 +0000 Subject: [PATCH 06/10] remove formatting changes from vite file --- src/frontend/vite.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index fdbebca9972d..627a1ade6e1b 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -82,7 +82,6 @@ export default defineConfig(({ command, mode }) => { changeOrigin: true, secure: true } - }, watch: { // Use polling only for WSL as the file system doesn't trigger notifications for Linux apps From ce0d3c8ae392edb5552b68d9b36679368d2ffb66 Mon Sep 17 00:00:00 2001 From: Neil Beueks Date: Mon, 1 Jun 2026 19:57:02 +0000 Subject: [PATCH 07/10] Bumped API version, added entry in docs for new global setting --- docs/docs/settings/global.md | 1 + src/backend/InvenTree/InvenTree/api_version.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index 752ef1b91082..f27e4b50df84 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -224,6 +224,7 @@ Configuration of stock item options {{ globalsetting("STOCK_SHOW_INSTALLED_ITEMS") }} {{ globalsetting("STOCK_ENFORCE_BOM_INSTALLATION") }} {{ globalsetting("STOCK_ALLOW_OUT_OF_STOCK_TRANSFER") }} +{{ globalsetting("STOCK_MERGE_ON_TRANSFER") }} {{ globalsetting("TEST_STATION_DATA") }} ### Build Orders diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 82d1c6dba367..5aeaa4e5357b 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 497 +INVENTREE_API_VERSION = 498 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" 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; + v497 -> 2026-05-27 : https://github.com/inventree/InvenTree/pull/12019 - Adds "location" field to StockCount API endpoint From f608f3364102bcbf6f2917ccf30986befca170fe Mon Sep 17 00:00:00 2001 From: Neil Beueks Date: Mon, 1 Jun 2026 20:50:28 +0000 Subject: [PATCH 08/10] removed the tracking item overwrite, Use existing tracking event from transfer --- .../InvenTree/InvenTree/api_version.py | 2 +- src/backend/InvenTree/stock/models.py | 65 ++++++++++++------- src/backend/InvenTree/stock/serializers.py | 15 +++++ src/backend/InvenTree/stock/test_api.py | 35 ++++++++++ 4 files changed, 93 insertions(+), 24 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5aeaa4e5357b..061c60ea486b 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -9,7 +9,7 @@ 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 559a9c90aa29..0834e2c67c02 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2307,25 +2307,27 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): self.parent = None self.save() - if other.location: - location_note = _('Transferred from %(location)s') % { - 'location': other.location.pathstring - } + other.delete() - notes = f'{notes}\n{location_note}' if notes else location_note + transfer_deltas = kwargs.pop('transfer_deltas', None) - other.delete() + 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 @@ def splitStock(self, quantity, location=None, user=None, **kwargs): 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 @@ def splitStock(self, quantity, location=None, user=None, **kwargs): """ # 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 @@ def splitStock(self, quantity, location=None, user=None, **kwargs): 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 @@ def splitStock(self, quantity, location=None, user=None, **kwargs): notes=notes, location=location, stockitem=new_stock, + record_tracking=record_tracking, ) # Rebuild the tree for this parent item @@ -2800,7 +2815,10 @@ def take_stock(self, quantity, user, code=StockHistoryCode.STOCK_REMOVE, **kwarg 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 @@ def take_stock(self, quantity, user, code=StockHistoryCode.STOCK_REMOVE, **kwarg 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 dc16476d15d1..ea558c7a4c69 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1907,16 +1907,31 @@ def save(self): } 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 7b7316cc1778..ebe1a0e4bd01 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2448,6 +2448,7 @@ def test_transfer_merge_does_not_copy_source_tracking(self): 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 @@ def test_transfer_merge_does_not_copy_source_tracking(self): 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): From 3cbf2f4fd4ac80352eb007847df5bc0414b1f771 Mon Sep 17 00:00:00 2001 From: Neil Beueks Date: Fri, 5 Jun 2026 13:06:05 +0000 Subject: [PATCH 09/10] run pre-commit checks --- src/backend/InvenTree/InvenTree/api_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 4fcc2b9b2ba4..3c2f9c8bd1f2 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -10,7 +10,7 @@ v500 -> 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. v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057 - Fixes search field issues on the BarcodeScanHistory API endpoint From ff8b9cf77933df050c1db02981e873a357b720e7 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 6 Jun 2026 17:36:17 +0200 Subject: [PATCH 10/10] fix style --- src/frontend/src/forms/StockForms.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 92ab428a347a..a0de3608b250 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -35,7 +35,14 @@ import { import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import { type JSX, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { + type JSX, + Suspense, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; import { useFormContext } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { api } from '../App';