mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-04 06:00:38 +00:00
* 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:
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user