2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 06:00:38 +00:00

Allow stock transfer to merge into existing stock (optional) #6951 (#12022)

* working on merge transfer

* fix history when merging

* update messaging, add 'added' line in history

* reworked history logic

* removed old transfer logic

* remove formatting changes from vite file

* Bumped API version, added entry in docs for new global setting

* removed the tracking item overwrite, Use existing tracking event from transfer

* run pre-commit checks

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Neil Beukes
2026-06-24 16:24:33 +02:00
committed by GitHub
parent fa55917659
commit 4cfefc18c1
9 changed files with 366 additions and 24 deletions
+1
View File
@@ -226,6 +226,7 @@ Configuration of stock item options
{{ globalsetting("STOCK_SHOW_INSTALLED_ITEMS") }} {{ globalsetting("STOCK_SHOW_INSTALLED_ITEMS") }}
{{ globalsetting("STOCK_ENFORCE_BOM_INSTALLATION") }} {{ globalsetting("STOCK_ENFORCE_BOM_INSTALLATION") }}
{{ globalsetting("STOCK_ALLOW_OUT_OF_STOCK_TRANSFER") }} {{ globalsetting("STOCK_ALLOW_OUT_OF_STOCK_TRANSFER") }}
{{ globalsetting("STOCK_MERGE_ON_TRANSFER") }}
{{ globalsetting("TEST_STATION_DATA") }} {{ globalsetting("TEST_STATION_DATA") }}
### Build Orders ### Build Orders
@@ -1,11 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 511 INVENTREE_API_VERSION = 512
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v512 -> 2026-06-20 : 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.
v511 -> 2026-06-19 : https://github.com/inventree/InvenTree/pull/12204 v511 -> 2026-06-19 : https://github.com/inventree/InvenTree/pull/12204
- Adds new filtering options to PartCategoryTree and StockLocationTree API endpoints - Adds new filtering options to PartCategoryTree and StockLocationTree API endpoints
@@ -793,6 +793,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, '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': { 'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Pattern'), 'name': _('Build Order Reference Pattern'),
'description': _('Required pattern for generating Build Order reference field'), 'description': _('Required pattern for generating Build Order reference field'),
+76 -17
View File
@@ -2206,6 +2206,35 @@ class StockItem(
return True 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 @transaction.atomic
def merge_stock_items(self, other_items, raise_error=False, **kwargs): def merge_stock_items(self, other_items, raise_error=False, **kwargs):
"""Merge another stock item into this one; the two become one! """Merge another stock item into this one; the two become one!
@@ -2227,7 +2256,7 @@ class StockItem(
user = kwargs.get('user') user = kwargs.get('user')
location = kwargs.get('location', self.location) 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 parent_id = self.parent.pk if self.parent else None
@@ -2245,9 +2274,12 @@ class StockItem(
) )
return return
merged_quantity = Decimal(0)
for other in other_items: for other in other_items:
tree_ids.add(other.tree_id) tree_ids.add(other.tree_id)
merged_quantity += other.quantity
self.quantity += other.quantity self.quantity += other.quantity
if other.purchase_price: if other.purchase_price:
@@ -2271,15 +2303,25 @@ class StockItem(
other.delete() 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( self.add_tracking_entry(
StockHistoryCode.MERGED_STOCK_ITEMS, StockHistoryCode.MERGED_STOCK_ITEMS,
user, user,
quantity=self.quantity, quantity=self.quantity,
notes=notes, notes=notes,
deltas={ deltas=tracking_deltas,
'location': location.pk if location else None,
'quantity': self.quantity,
},
) )
# Update the location of the item # Update the location of the item
@@ -2340,6 +2382,8 @@ class StockItem(
status: If provided, override the status (default = existing status) status: If provided, override the status (default = existing status)
packaging: If provided, override the packaging (default = existing packaging) packaging: If provided, override the packaging (default = existing packaging)
allow_production: If True, allow splitting of stock which is in production (default = False) 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: Returns:
The new StockItem object The new StockItem object
@@ -2352,6 +2396,8 @@ class StockItem(
""" """
# Run initial checks to test if the stock item can actually be "split" # Run initial checks to test if the stock item can actually be "split"
allow_production = kwargs.get('allow_production', False) 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 # Cannot split a stock item which is in production
if self.is_building and not allow_production: if self.is_building and not allow_production:
@@ -2424,15 +2470,23 @@ class StockItem(
new_stock.save(add_note=False) new_stock.save(add_note=False)
# Add a stock tracking entry for the newly created item if split_transfer_deltas is not None:
new_stock.add_tracking_entry( split_transfer_deltas.clear()
StockHistoryCode.SPLIT_FROM_PARENT, split_transfer_deltas.update(deltas)
user,
quantity=quantity, if location:
notes=notes, split_transfer_deltas['location'] = location.pk
location=location,
deltas=deltas, 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 # Copy the test results of this part to the new one
new_stock.copyTestResultsFrom(self) new_stock.copyTestResultsFrom(self)
@@ -2445,6 +2499,7 @@ class StockItem(
notes=notes, notes=notes,
location=location, location=location,
stockitem=new_stock, stockitem=new_stock,
record_tracking=record_tracking,
) )
# Rebuild the tree for this parent item # Rebuild the tree for this parent item
@@ -2754,7 +2809,10 @@ class StockItem(
code: The stock history code to use code: The stock history code to use
notes: Optional notes for the stock removal notes: Optional notes for the stock removal
status: Optionally adjust the stock status 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 # Cannot remove items from a serialized part
if self.serialized: if self.serialized:
return False return False
@@ -2804,9 +2862,10 @@ class StockItem(
self.save(add_note=False) self.save(add_note=False)
self.add_tracking_entry( if record_tracking:
code, user, notes=kwargs.get('notes', ''), deltas=deltas self.add_tracking_entry(
) code, user, notes=kwargs.get('notes', ''), deltas=deltas
)
return True return True
+52 -1
View File
@@ -1648,7 +1648,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
fields = ['pk', 'quantity', 'batch', 'status', 'packaging'] fields = ['pk', 'quantity', 'batch', 'status', 'packaging', 'merge']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize the serializer.""" """Initialize the serializer."""
@@ -1722,6 +1722,15 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
help_text=_('Packaging this stock item is stored in'), 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): class StockAdjustmentSerializer(serializers.Serializer):
"""Base class for managing stock adjustment actions via the API.""" """Base class for managing stock adjustment actions via the API."""
@@ -1888,6 +1897,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
# Required fields # Required fields
stock_item = item['pk'] stock_item = item['pk']
quantity = item['quantity'] quantity = item['quantity']
merge = item.get('merge', False)
# Optional fields # Optional fields
kwargs = {} kwargs = {}
@@ -1896,6 +1906,47 @@ class StockTransferSerializer(StockAdjustmentSerializer):
if field_value := item.get(field_name, None): if field_value := item.get(field_name, None):
kwargs[field_name] = field_value 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( stock_item.move(
location, notes, request.user, quantity=quantity, **kwargs location, notes, request.user, quantity=quantity, **kwargs
) )
+172
View File
@@ -2412,6 +2412,178 @@ class StocktakeTest(StockAPITestCase):
self.assertIn('does not exist', str(response.data['location'])) 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'
)
incoming_pk = incoming.pk
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)
self.assertEqual(merge_entry.deltas['stockitem'], incoming_pk)
self.assertEqual(merge_entry.deltas['location'], self.dest.pk)
def test_transfer_merge_partial_reuses_split_transfer_deltas(self):
"""Partial merge reuses split transfer deltas on the merge tracking 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=100
)
self.post(
self.url,
{
'items': [{'pk': incoming.pk, 'quantity': 30, 'merge': True}],
'location': self.dest.pk,
},
expected_code=201,
)
incoming.refresh_from_db()
self.assertEqual(incoming.quantity, 70)
merge_entry = existing.tracking_info.filter(
tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS
).first()
self.assertEqual(merge_entry.deltas['stockitem'], incoming.pk)
self.assertEqual(merge_entry.deltas['location'], self.dest.pk)
self.assertFalse(
incoming.tracking_info.filter(
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM
).exists()
)
class StockItemDeletionTest(StockAPITestCase): class StockItemDeletionTest(StockAPITestCase):
"""Tests for stock item deletion via the API.""" """Tests for stock item deletion via the API."""
+7
View File
@@ -729,6 +729,13 @@ class StockTest(StockTestBase):
self.assertEqual(s1.quantity, 60) self.assertEqual(s1.quantity, 60)
self.assertIsNone(s1.purchase_price) 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() part.stock_items.all().delete()
# Create some stock items with pricing information # Create some stock items with pricing information
+43 -5
View File
@@ -33,8 +33,16 @@ import {
IconUsersGroup IconUsersGroup
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { type JSX, Suspense, useEffect, useMemo, useState } from 'react'; import {
type JSX,
Suspense,
useCallback,
useEffect,
useMemo,
useState
} from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../App'; import { api } from '../App';
@@ -566,6 +574,7 @@ function StockOperationsRow({
add = false, add = false,
setMax = false, setMax = false,
merge = false, merge = false,
transferMerge = false,
returnStock = false, returnStock = false,
record record
}: { }: {
@@ -575,6 +584,7 @@ function StockOperationsRow({
add?: boolean; add?: boolean;
setMax?: boolean; setMax?: boolean;
merge?: boolean; merge?: boolean;
transferMerge?: boolean;
returnStock?: boolean; returnStock?: boolean;
record?: any; record?: any;
}) { }) {
@@ -742,6 +752,17 @@ function StockOperationsRow({
variant={packagingOpen ? 'filled' : 'transparent'} variant={packagingOpen ? 'filled' : 'transparent'}
/> />
)} )}
{transferMerge && (
<ActionButton
size='sm'
icon={<InvenTreeIcon icon='merge' />}
tooltip={t`Merge into existing stock`}
onClick={() =>
callChangeFn(props.idx, 'merge', !props.item?.merge)
}
variant={props.item?.merge ? 'filled' : 'transparent'}
/>
)}
<RemoveRowButton onClick={() => props.removeFn(props.idx)} /> <RemoveRowButton onClick={() => props.removeFn(props.idx)} />
</Flex> </Flex>
</Table.Td> </Table.Td>
@@ -789,9 +810,10 @@ type StockAdjustmentItem = {
batch?: string; batch?: string;
status?: number | '' | null; status?: number | '' | null;
packaging?: string; packaging?: string;
merge?: boolean;
}; };
function mapAdjustmentItems(items: any[]) { function mapAdjustmentItems(items: any[], mergeDefault?: boolean) {
const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => { const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => {
return { return {
pk: elem.pk, pk: elem.pk,
@@ -799,6 +821,7 @@ function mapAdjustmentItems(items: any[]) {
batch: elem.batch || undefined, batch: elem.batch || undefined,
status: elem.status || undefined, status: elem.status || undefined,
packaging: elem.packaging || undefined, packaging: elem.packaging || undefined,
merge: elem.merge ?? mergeDefault ?? false,
obj: elem obj: elem
}; };
}); });
@@ -806,7 +829,10 @@ function mapAdjustmentItems(items: any[]) {
return mappedItems; return mappedItems;
} }
function stockTransferFields(items: any[]): ApiFormFieldSet { function stockTransferFields(
items: any[],
mergeDefault = false
): ApiFormFieldSet {
if (!items) { if (!items) {
return {}; return {};
} }
@@ -819,7 +845,7 @@ function stockTransferFields(items: any[]): ApiFormFieldSet {
const fields: ApiFormFieldSet = { const fields: ApiFormFieldSet = {
items: { items: {
field_type: 'table', field_type: 'table',
value: mapAdjustmentItems(items), value: mapAdjustmentItems(items, mergeDefault),
modelRenderer: (row: TableFieldRowProps) => { modelRenderer: (row: TableFieldRowProps) => {
const record = records[row.item.pk]; const record = records[row.item.pk];
@@ -829,6 +855,7 @@ function stockTransferFields(items: any[]): ApiFormFieldSet {
transfer transfer
changeStatus changeStatus
setMax setMax
transferMerge
key={record.pk} key={record.pk}
record={record} record={record}
/> />
@@ -1379,9 +1406,20 @@ export function useRemoveStockItem(props: StockOperationProps) {
} }
export function useTransferStockItem(props: StockOperationProps) { export function useTransferStockItem(props: StockOperationProps) {
const globalSettings = useGlobalSettingsState();
const fieldGenerator = useCallback(
(items: any[]) =>
stockTransferFields(
items,
globalSettings.isSet('STOCK_MERGE_ON_TRANSFER')
),
[globalSettings]
);
return useStockOperationModal({ return useStockOperationModal({
...props, ...props,
fieldGenerator: stockTransferFields, fieldGenerator: fieldGenerator,
endpoint: ApiEndpoints.stock_transfer, endpoint: ApiEndpoints.stock_transfer,
title: t`Transfer Stock`, title: t`Transfer Stock`,
successMessage: t`Stock transferred`, successMessage: t`Stock transferred`,
@@ -273,6 +273,7 @@ export default function SystemSettings() {
'STOCK_SHOW_INSTALLED_ITEMS', 'STOCK_SHOW_INSTALLED_ITEMS',
'STOCK_ENFORCE_BOM_INSTALLATION', 'STOCK_ENFORCE_BOM_INSTALLATION',
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', 'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER',
'STOCK_MERGE_ON_TRANSFER',
'TEST_STATION_DATA' 'TEST_STATION_DATA'
]} ]}
/> />