Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/settings/global.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 = 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)

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 @@ -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!
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

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 @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -1877,6 +1886,7 @@ def save(self):
# Required fields
stock_item = item['pk']
quantity = item['quantity']
merge = item.get('merge', False)

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