Skip to content
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
44 changes: 42 additions & 2 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 @@ -2275,6 +2307,13 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs):
self.parent = None
self.save()

if other.location:
location_note = _('Transferred from %(location)s') % {
'location': other.location.pathstring
}

notes = f'{notes}\n{location_note}' if notes else location_note
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like it will be very hard to trace back and also like a data loss

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any newlines are stripped out from char fields too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You guys are right, thanks for pointing it out. Stripping the existing tracking event and replacing it with a lesser detailed event makes no sense. Im mostly reusing the existing event now.
image


other.delete()

self.add_tracking_entry(
Expand All @@ -2284,7 +2323,8 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs):
notes=notes,
deltas={
'location': location.pk if location else None,
'quantity': self.quantity,
'quantity': float(self.quantity),
'added': float(merged_quantity),
},
)

Expand Down
38 changes: 37 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,32 @@ 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:
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
)
Expand Down
137 changes: 137 additions & 0 deletions src/backend/InvenTree/stock/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2336,6 +2336,143 @@ 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'
)

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)


class StockItemDeletionTest(StockAPITestCase):
"""Tests for stock item deletion via the API."""

Expand Down
7 changes: 7 additions & 0 deletions src/backend/InvenTree/stock/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading