From 1007913a930c9ddb2106e1b36211a695a57a4ba0 Mon Sep 17 00:00:00 2001 From: Neil Beukes Date: Tue, 26 May 2026 15:10:32 +0000 Subject: [PATCH] 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 c646aa843d..ba9e60e87c 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -787,6 +787,15 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { '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 040daee86e..d3274e6a47 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2179,6 +2179,34 @@ class StockItem( 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 @@ class StockItem( *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 @@ class StockItem( 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 @@ class StockItem( 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 570b4f7e51..6b857ac403 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 StockTransferSerializer(StockAdjustmentSerializer): 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 @@ class StockTransferSerializer(StockAdjustmentSerializer): 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 @@ class StockTransferSerializer(StockAdjustmentSerializer): 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 d274fffafa..a4961d829d 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2154,6 +2154,106 @@ class StocktakeTest(StockAPITestCase): ) +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 9495432366..be3c683dc3 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -722,12 +722,24 @@ class StockTest(StockTestBase): 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 547d2b2087..790f74d6ed 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 0078520bd9..c7a090a69d 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 627a1ade6e..fdbebca997 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