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'
]}
/>