diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py
index ba9e60e87c..ebe15df8ec 100644
--- a/src/backend/InvenTree/common/setting/system.py
+++ b/src/backend/InvenTree/common/setting/system.py
@@ -788,10 +788,9 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'validator': bool,
},
'STOCK_MERGE_ON_TRANSFER': {
- 'name': _('Merge stock with existing stock on transfer'),
+ 'name': _('Merge stock with existing stock on transfer by default'),
'description': _(
- 'Default: when transferring stock, merge into an existing lot at the '
- 'destination if possible (uses the same rules as manual stock merge)'
+ 'Default state for merge stock on transfer behaviour. (Can be changed per transfer if desired)'
),
'default': False,
'validator': bool,
diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py
index 42eff73c01..8a12bde90d 100644
--- a/src/backend/InvenTree/stock/models.py
+++ b/src/backend/InvenTree/stock/models.py
@@ -2251,9 +2251,12 @@ class StockItem(
)
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:
@@ -2294,7 +2297,8 @@ class StockItem(
notes=notes,
deltas={
'location': location.pk if location else None,
- 'quantity': self.quantity,
+ 'quantity': float(self.quantity),
+ 'added': float(merged_quantity),
},
)
diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py
index fe43a8047c..7ca257cfcb 100644
--- a/src/backend/InvenTree/stock/serializers.py
+++ b/src/backend/InvenTree/stock/serializers.py
@@ -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."""
@@ -1709,6 +1709,15 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
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."""
@@ -1860,13 +1869,14 @@ class StockTransferSerializer(StockAdjustmentSerializer):
items = data['items']
notes = data.get('notes', '')
location = data['location']
- merge = data.get('merge', False)
+ default_merge = data.get('merge', False)
with transaction.atomic():
for item in items:
# Required fields
stock_item = item['pk']
quantity = item['quantity']
+ merge = item.get('merge', default_merge)
# Optional fields
kwargs = {}
diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py
index 67eb591c12..2ffbbcd837 100644
--- a/src/backend/InvenTree/stock/test_api.py
+++ b/src/backend/InvenTree/stock/test_api.py
@@ -2181,9 +2181,8 @@ class StockTransferMergeTest(StockAPITestCase):
self.post(
self.url,
{
- 'items': [{'pk': incoming.pk, 'quantity': 50}],
+ 'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': False}],
'location': self.dest.pk,
- 'merge': False,
},
expected_code=201,
)
@@ -2207,9 +2206,8 @@ class StockTransferMergeTest(StockAPITestCase):
self.post(
self.url,
{
- 'items': [{'pk': incoming.pk, 'quantity': 50}],
+ 'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': True}],
'location': self.dest.pk,
- 'merge': True,
},
expected_code=201,
)
@@ -2222,6 +2220,39 @@ class StockTransferMergeTest(StockAPITestCase):
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(
@@ -2240,9 +2271,8 @@ class StockTransferMergeTest(StockAPITestCase):
self.post(
self.url,
{
- 'items': [{'pk': incoming.pk, 'quantity': 50}],
+ 'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': True}],
'location': self.dest.pk,
- 'merge': True,
},
expected_code=201,
)
@@ -2253,11 +2283,12 @@ class StockTransferMergeTest(StockAPITestCase):
existing.tracking_info.filter(notes='Source tracking entry').exists()
)
self.assertEqual(existing.tracking_info.count(), tracking_count + 1)
- self.assertTrue(
- existing.tracking_info.filter(
- tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS
- ).exists()
- )
+ 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):
diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py
index be3c683dc3..aa453025f4 100644
--- a/src/backend/InvenTree/stock/tests.py
+++ b/src/backend/InvenTree/stock/tests.py
@@ -723,9 +723,7 @@ class StockTest(StockTestBase):
s3 = StockItem.objects.create(part=part, quantity=30)
s2.add_tracking_entry(
- StockHistoryCode.STOCK_UPDATE,
- None,
- notes='Merged away tracking',
+ StockHistoryCode.STOCK_UPDATE, None, notes='Merged away tracking'
)
tracking_before = s1.tracking_info.count()
@@ -736,11 +734,16 @@ class StockTest(StockTestBase):
s1.refresh_from_db()
self.assertEqual(s1.quantity, 60)
self.assertIsNone(s1.purchase_price)
- self.assertTrue(
- s1.tracking_info.filter(notes='Merged away tracking').exists()
- )
+ self.assertTrue(s1.tracking_info.filter(notes='Merged away tracking').exists())
self.assertGreater(s1.tracking_info.count(), tracking_before)
+ 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 790f74d6ed..5ae4fd3cbd 100644
--- a/src/frontend/src/forms/StockForms.tsx
+++ b/src/frontend/src/forms/StockForms.tsx
@@ -555,6 +555,7 @@ function StockOperationsRow({
add = false,
setMax = false,
merge = false,
+ transferMerge = false,
record
}: {
props: TableFieldRowProps;
@@ -563,6 +564,7 @@ function StockOperationsRow({
add?: boolean;
setMax?: boolean;
merge?: boolean;
+ transferMerge?: boolean;
record?: any;
}) {
const statusOptions: ApiFormFieldChoice[] = useMemo(() => {
@@ -712,6 +714,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)} />
@@ -759,9 +772,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,
@@ -769,6 +783,7 @@ function mapAdjustmentItems(items: any[]) {
batch: elem.batch || undefined,
status: elem.status || undefined,
packaging: elem.packaging || undefined,
+ merge: elem.merge ?? mergeDefault ?? false,
obj: elem
};
});
@@ -776,7 +791,10 @@ function mapAdjustmentItems(items: any[]) {
return mappedItems;
}
-function stockTransferFields(items: any[]): ApiFormFieldSet {
+function stockTransferFields(
+ items: any[],
+ mergeDefault = false
+): ApiFormFieldSet {
if (!items) {
return {};
}
@@ -789,7 +807,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];
@@ -799,6 +817,7 @@ function stockTransferFields(items: any[]): ApiFormFieldSet {
transfer
changeStatus
setMax
+ transferMerge
key={record.pk}
record={record}
/>
@@ -1329,12 +1348,11 @@ export function useTransferStockItem(props: StockOperationProps) {
const globalSettings = useGlobalSettingsState();
const fieldGenerator = useCallback(
- (items: any[]) => ({
- ...stockTransferFields(items),
- merge: {
- default: globalSettings.isSet('STOCK_MERGE_ON_TRANSFER')
- }
- }),
+ (items: any[]) =>
+ stockTransferFields(
+ items,
+ globalSettings.isSet('STOCK_MERGE_ON_TRANSFER')
+ ),
[globalSettings]
);