diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index efacfb4fbb2a..67638dba0cd9 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -225,6 +225,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 bfd21e84c52f..81e1432a603e 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 501 +INVENTREE_API_VERSION = 502 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ + +v502 -> 2026-06-06 : 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. + v501 -> 2026-06-05 : https://github.com/inventree/InvenTree/pull/12093 - Adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API) diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index f6b41a4cc0b7..e6713918687a 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -787,6 +787,14 @@ class SystemSetId: 'default': False, 'validator': bool, }, + 'STOCK_MERGE_ON_TRANSFER': { + 'name': _('Merge stock with existing stock on transfer by default'), + 'description': _( + 'Default state for merge stock on transfer behaviour. (Can be changed per transfer if desired)' + ), + '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 c7c4f653febc..290377ff0be6 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2206,6 +2206,35 @@ 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! @@ -2227,7 +2256,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 @@ -2245,9 +2274,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: @@ -2271,15 +2303,25 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs): 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': self.quantity, - }, + deltas=tracking_deltas, ) # Update the location of the item @@ -2340,6 +2382,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 @@ -2352,6 +2396,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: @@ -2424,15 +2470,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) @@ -2445,6 +2499,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 @@ -2754,7 +2809,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 @@ -2804,9 +2862,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 4943dd6dfee6..0936e8a22e3d 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1637,7 +1637,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.""" @@ -1711,6 +1711,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.""" @@ -1877,6 +1886,7 @@ def save(self): # Required fields stock_item = item['pk'] quantity = item['quantity'] + merge = item.get('merge', False) # Optional fields kwargs = {} @@ -1885,6 +1895,47 @@ 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: + 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 + 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 4299cfa83c95..fdc0ebce54e2 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2412,6 +2412,178 @@ def test_count_with_location(self): self.assertIn('does not exist', str(response.data['location'])) +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') + + # 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( + 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, '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, 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, 'merge': True}], + 'location': self.dest.pk, + }, + 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_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( + 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' + ) + + incoming_pk = incoming.pk + tracking_count = existing.tracking_info.count() + + self.post( + self.url, + { + 'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': True}], + 'location': self.dest.pk, + }, + expected_code=201, + ) + + existing.refresh_from_db() + + self.assertFalse( + existing.tracking_info.filter(notes='Source tracking entry').exists() + ) + self.assertEqual(existing.tracking_info.count(), tracking_count + 1) + 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) + 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): """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..999fc030b0d2 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -729,6 +729,13 @@ def test_merge(self): self.assertEqual(s1.quantity, 60) self.assertIsNone(s1.purchase_price) + 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 99255cf0d5e5..a0de3608b250 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -33,8 +33,16 @@ import { IconUsersGroup } from '@tabler/icons-react'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; + import dayjs from 'dayjs'; -import { type JSX, Suspense, 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'; @@ -566,6 +574,7 @@ function StockOperationsRow({ add = false, setMax = false, merge = false, + transferMerge = false, returnStock = false, record }: { @@ -575,6 +584,7 @@ function StockOperationsRow({ add?: boolean; setMax?: boolean; merge?: boolean; + transferMerge?: boolean; returnStock?: boolean; record?: any; }) { @@ -742,6 +752,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)} /> @@ -789,9 +810,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, @@ -799,6 +821,7 @@ function mapAdjustmentItems(items: any[]) { batch: elem.batch || undefined, status: elem.status || undefined, packaging: elem.packaging || undefined, + merge: elem.merge ?? mergeDefault ?? false, obj: elem }; }); @@ -806,7 +829,10 @@ function mapAdjustmentItems(items: any[]) { return mappedItems; } -function stockTransferFields(items: any[]): ApiFormFieldSet { +function stockTransferFields( + items: any[], + mergeDefault = false +): ApiFormFieldSet { if (!items) { return {}; } @@ -819,7 +845,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]; @@ -829,6 +855,7 @@ function stockTransferFields(items: any[]): ApiFormFieldSet { transfer changeStatus setMax + transferMerge key={record.pk} record={record} /> @@ -1379,9 +1406,20 @@ export function useRemoveStockItem(props: StockOperationProps) { } export function useTransferStockItem(props: StockOperationProps) { + const globalSettings = useGlobalSettingsState(); + + const fieldGenerator = useCallback( + (items: any[]) => + stockTransferFields( + items, + 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 61123741c552..11fa0bca4b94 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -272,6 +272,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' ]} />