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] );