working on merge transfer

This commit is contained in:
Neil Beukes 2026-05-26 15:10:32 +00:00
parent b8233c1899
commit 1007913a93
8 changed files with 225 additions and 5 deletions

View File

@ -787,6 +787,15 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'STOCK_MERGE_ON_TRANSFER': {
'name': _('Merge stock with existing stock on transfer'),
'description': _(
'Default: when transferring stock, merge into an existing lot at the '
'destination if possible (uses the same rules as manual stock merge)'
),
'default': False,
'validator': bool,
},
'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Pattern'),
'description': _('Required pattern for generating Build Order reference field'),

View File

@ -2179,6 +2179,34 @@ class StockItem(
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!
@ -2186,7 +2214,7 @@ class StockItem(
*This* stock item subsumes the other, which is essentially deleted:
- The quantity of this StockItem is increased
- Tracking history for the *other* item is deleted
- Tracking history for the *other* item is copied to this item
- Any allocations (build order, sales order) are moved to this StockItem
"""
if isinstance(other_items, StockItem):
@ -2200,7 +2228,7 @@ class StockItem(
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
@ -2242,6 +2270,18 @@ class StockItem(
self.parent = None
self.save()
self.copyHistoryFrom(other)
if other.location:
location_note = _('Transferred from %(location)s') % {
'location': other.location.pathstring
}
if notes:
notes = f'{notes}\n{location_note}'
else:
notes = location_note
other.delete()
self.add_tracking_entry(

View File

@ -1829,7 +1829,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
class Meta:
"""Metaclass options."""
fields = ['items', 'notes', 'location']
fields = ['items', 'notes', 'location', 'merge']
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
@ -1842,6 +1842,15 @@ class StockTransferSerializer(StockAdjustmentSerializer):
help_text=_('Destination stock location'),
)
merge = serializers.BooleanField(
default=False,
required=False,
label=_('Merge into existing stock'),
help_text=_(
'Merge transferred items into existing stock items at the destination if possible'
),
)
def save(self):
"""Transfer stock."""
request = self.context['request']
@ -1851,6 +1860,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
items = data['items']
notes = data.get('notes', '')
location = data['location']
merge = data.get('merge', False)
with transaction.atomic():
for item in items:
@ -1865,6 +1875,34 @@ class StockTransferSerializer(StockAdjustmentSerializer):
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
)

View File

@ -2154,6 +2154,106 @@ class StocktakeTest(StockAPITestCase):
)
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')
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}],
'location': self.dest.pk,
'merge': False,
},
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}],
'location': self.dest.pk,
'merge': True,
},
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_merge_preserves_tracking(self):
"""Transfer merge copies tracking history onto the surviving stock item."""
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}],
'location': self.dest.pk,
'merge': True,
},
expected_code=201,
)
existing.refresh_from_db()
self.assertTrue(
existing.tracking_info.filter(notes='Source tracking entry').exists()
)
self.assertGreater(existing.tracking_info.count(), tracking_count)
class StockItemDeletionTest(StockAPITestCase):
"""Tests for stock item deletion via the API."""

View File

@ -722,12 +722,24 @@ class StockTest(StockTestBase):
s2 = StockItem.objects.create(part=part, quantity=20)
s3 = StockItem.objects.create(part=part, quantity=30)
s2.add_tracking_entry(
StockHistoryCode.STOCK_UPDATE,
None,
notes='Merged away tracking',
)
tracking_before = s1.tracking_info.count()
self.assertEqual(part.stock_items.count(), 3)
s1.merge_stock_items([s2, s3])
self.assertEqual(part.stock_items.count(), 1)
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.assertGreater(s1.tracking_info.count(), tracking_before)
part.stock_items.all().delete()

View File

@ -20,7 +20,14 @@ import {
IconUsersGroup
} from '@tabler/icons-react';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { type JSX, Suspense, useEffect, useMemo, useState } from 'react';
import {
type JSX,
Suspense,
useCallback,
useEffect,
useMemo,
useState
} from 'react';
import { ActionButton } from '@lib/components/ActionButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
@ -1319,9 +1326,21 @@ export function useRemoveStockItem(props: StockOperationProps) {
}
export function useTransferStockItem(props: StockOperationProps) {
const globalSettings = useGlobalSettingsState();
const fieldGenerator = useCallback(
(items: any[]) => ({
...stockTransferFields(items),
merge: {
default: globalSettings.isSet('STOCK_MERGE_ON_TRANSFER')
}
}),
[globalSettings]
);
return useStockOperationModal({
...props,
fieldGenerator: stockTransferFields,
fieldGenerator: fieldGenerator,
endpoint: ApiEndpoints.stock_transfer,
title: t`Transfer Stock`,
successMessage: t`Stock transferred`,

View File

@ -259,6 +259,7 @@ export default function SystemSettings() {
'STOCK_SHOW_INSTALLED_ITEMS',
'STOCK_ENFORCE_BOM_INSTALLATION',
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER',
'STOCK_MERGE_ON_TRANSFER',
'TEST_STATION_DATA'
]}
/>

View File

@ -82,6 +82,7 @@ export default defineConfig(({ command, mode }) => {
changeOrigin: true,
secure: true
}
},
watch: {
// Use polling only for WSL as the file system doesn't trigger notifications for Linux apps