working on merge transfer
This commit is contained in:
parent
b8233c1899
commit
1007913a93
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue