Skip to content
1 change: 1 addition & 0 deletions docs/docs/settings/global.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 499
INVENTREE_API_VERSION = 500
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """


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

v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057
- Fixes search field issues on the BarcodeScanHistory API endpoint

Expand Down
8 changes: 8 additions & 0 deletions src/backend/InvenTree/common/setting/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
93 changes: 76 additions & 17 deletions src/backend/InvenTree/stock/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2212,6 +2212,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!
Expand All @@ -2233,7 +2262,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

Expand All @@ -2251,9 +2280,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:
Expand All @@ -2277,15 +2309,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
Expand Down Expand Up @@ -2346,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
Expand All @@ -2358,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:
Expand Down Expand Up @@ -2430,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)
Expand All @@ -2451,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
Expand Down Expand Up @@ -2760,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
Expand Down Expand Up @@ -2810,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

Expand Down
53 changes: 52 additions & 1 deletion src/backend/InvenTree/stock/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -1875,6 +1884,7 @@ def save(self):
# Required fields
stock_item = item['pk']
quantity = item['quantity']
merge = item.get('merge', False)

# Optional fields
kwargs = {}
Expand All @@ -1883,6 +1893,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
)
Expand Down
Loading
Loading