From df90934f0ceaf2701f461a555888efc0ad84782b Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Tue, 27 Jan 2026 00:44:28 -0700 Subject: [PATCH] Stock Tracking - Add Old Status to Deltas (#11179) * match custom status tracking entry in edit * add old_status to stockitemtracking * test old_status tracking * use vars for readability * split custom status test * move custom status from fixture to setup * add old status to tracking table * fallback to logical status if custom removed * avoid shared deltas reference in loop * track old status in stock add/remove/count/transfer --------- Co-authored-by: Matthias Mair --- src/backend/InvenTree/stock/models.py | 89 +++++++++++++++++-- src/backend/InvenTree/stock/serializers.py | 29 ++++-- src/backend/InvenTree/stock/test_api.py | 82 +++++++++++++++++ .../src/components/render/StatusRenderer.tsx | 22 ++++- src/frontend/src/forms/BuildForms.tsx | 3 +- src/frontend/src/forms/StockForms.tsx | 3 +- src/frontend/src/pages/stock/StockDetail.tsx | 1 + .../src/tables/stock/StockTrackingTable.tsx | 17 +++- 8 files changed, 224 insertions(+), 22 deletions(-) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index d525bfb6dc..d987cedab9 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -795,12 +795,28 @@ class StockItem( try: old = StockItem.objects.get(pk=self.pk) + old_custom_status = old.get_custom_status() + custom_status = self.get_custom_status() deltas = {} # Status changed? if old.status != self.status: - deltas['status'] = self.status + # Custom status changed? + # Matches custom status tracking behavior of StockChangeStatusSerializer + if old_custom_status != custom_status: + deltas['status'] = custom_status + deltas['status_logical'] = self.status + else: + deltas['status'] = self.status + deltas['status_logical'] = self.status + + if old_custom_status: + deltas['old_status'] = old_custom_status + deltas['old_status_logical'] = old.status + else: + deltas['old_status'] = old.status + deltas['old_status_logical'] = old.status if add_note and len(deltas) > 0: self.add_tracking_entry( @@ -1446,8 +1462,17 @@ class StockItem( if status := kwargs.pop('status', None): if not item.compare_status(status): + old_custom_status = item.get_custom_status() + old_status_logical = item.status item.set_status(status) - tracking_info['status'] = status + tracking_info['status'] = status # may be a custom value + tracking_info['status_logicial'] = ( + item.status + ) # always the logical value + tracking_info['old_status'] = ( + old_custom_status if old_custom_status else old_status_logical + ) + tracking_info['old_status_logical'] = old_status_logical item.save() @@ -2268,8 +2293,26 @@ class StockItem( # Optional fields which can be supplied in a 'move' call for field in StockItem.optional_transfer_fields(): if field in kwargs: - setattr(new_stock, field, kwargs[field]) - deltas[field] = kwargs[field] + # handle specific case for status deltas + if field == 'status': + status = kwargs[field] + if not new_stock.compare_status(status): + old_custom_status = new_stock.get_custom_status() + old_status_logical = new_stock.status + new_stock.set_status(status) + deltas['status'] = status # may be a custom value + deltas['status_logicial'] = ( + new_stock.status + ) # always the logical value + deltas['old_status'] = ( + old_custom_status + if old_custom_status + else old_status_logical + ) + deltas['old_status_logical'] = old_status_logical + else: + setattr(new_stock, field, kwargs[field]) + deltas[field] = kwargs[field] new_stock.save(add_note=False) @@ -2385,8 +2428,15 @@ class StockItem( status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) if status and not self.compare_status(status): + old_custom_status = self.get_custom_status() + old_status_logical = self.status self.set_status(status) - tracking_info['status'] = status + tracking_info['status'] = status # may be a custom value + tracking_info['status_logicial'] = self.status # always the logical value + tracking_info['old_status'] = ( + old_custom_status if old_custom_status else old_status_logical + ) + tracking_info['old_status_logical'] = old_status_logical # Optional fields which can be supplied in a 'move' call for field in StockItem.optional_transfer_fields(): @@ -2437,7 +2487,7 @@ class StockItem( return False - self.save() + self.save(add_note=False) trigger_event( StockEvents.ITEM_QUANTITY_UPDATED, id=self.id, quantity=float(self.quantity) @@ -2470,8 +2520,15 @@ class StockItem( status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) if status and not self.compare_status(status): + old_custom_status = self.get_custom_status() + old_status_logical = self.status self.set_status(status) - tracking_info['status'] = status + tracking_info['status'] = status # may be a custom value + tracking_info['status_logicial'] = self.status # always the logical value + tracking_info['old_status'] = ( + old_custom_status if old_custom_status else old_status_logical + ) + tracking_info['old_status_logical'] = old_status_logical if self.updateQuantity(count): tracking_info['quantity'] = float(count) @@ -2533,8 +2590,15 @@ class StockItem( status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) if status and not self.compare_status(status): + old_custom_status = self.get_custom_status() + old_status_logical = self.status self.set_status(status) - tracking_info['status'] = status + tracking_info['status'] = status # may be a custom value + tracking_info['status_logicial'] = self.status # always the logical value + tracking_info['old_status'] = ( + old_custom_status if old_custom_status else old_status_logical + ) + tracking_info['old_status_logical'] = old_status_logical if self.updateQuantity(self.quantity + quantity): tracking_info['added'] = float(quantity) @@ -2587,8 +2651,15 @@ class StockItem( status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) if status and not self.compare_status(status): + old_custom_status = self.get_custom_status() + old_status_logical = self.status self.set_status(status) - deltas['status'] = status + deltas['status'] = status # may be a custom value + deltas['status_logicial'] = self.status # always the logical value + deltas['old_status'] = ( + old_custom_status if old_custom_status else old_status_logical + ) + deltas['old_status_logical'] = old_status_logical if self.updateQuantity(self.quantity - quantity): deltas['removed'] = float(quantity) diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 35054ffe40..a1025ed536 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -463,12 +463,16 @@ class StockItemSerializer( status_custom_key = validated_data.pop('status_custom_key', None) status = validated_data.pop('status', None) - instance = super().update(instance, validated_data=validated_data) - if status_code := status_custom_key or status: - if not instance.compare_status(status_code): - instance.set_status(status_code) - instance.save() + # avoid a second .save() call and perform both status updates at once (to support `old_status` in tracking event) + # by setting the values in validated_data as computed by set_status() + instance.set_status(status_code) + validated_data['status'] = instance.status + validated_data['status_custom_key'] = ( + status_code # for compatibility with custom "leader/follower" concept in super().update() + ) + + instance = super().update(instance, validated_data=validated_data) return instance @@ -1053,8 +1057,6 @@ class StockChangeStatusSerializer(serializers.Serializer): transaction_notes = [] - deltas = {'status': status} - now = InvenTree.helpers.current_time() # Instead of performing database updates for each item, @@ -1072,9 +1074,22 @@ class StockChangeStatusSerializer(serializers.Serializer): if status == custom_status or custom_status is None: continue + deltas = {'status': status} + + # before save, track old status logical + deltas['old_status_logical'] = item.status + + if item.get_custom_status(): + deltas['old_status'] = item.get_custom_status() + else: + deltas['old_status'] = item.status + item.set_status(status, custom_values=custom_status_codes) item.save(add_note=False) + # after save, can track new status_logical + deltas['status_logical'] = item.status + # Create a new transaction note for each item transaction_notes.append( StockItemTracking( diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 4634ba40a3..b0d4f147f7 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -1315,6 +1315,17 @@ class StockItemTest(StockAPITestCase): StockLocation.objects.create(name='B', description='location b', parent=top) StockLocation.objects.create(name='C', description='location c', parent=top) + # Create a custom status + self.inspect_custom_status = InvenTreeCustomUserStateModel.objects.create( + key=150, + name='INSPECT', + label='Incoming goods inspection', + color='warning', + logical_key=50, + model=ContentType.objects.get(model='stockitem'), + reference_status='StockStatus', + ) + def test_create_default_location(self): """Test the default location functionality, if a 'location' is not specified in the creation request.""" # The part 'R_4K7_0603' (pk=4) has a default location specified @@ -1820,6 +1831,15 @@ class StockItemTest(StockAPITestCase): item.refresh_from_db() self.assertEqual(item.status, StockStatus.DAMAGED.value) self.assertEqual(item.tracking_info.count(), 2) + tracking = item.tracking_info.last() + self.assertEqual(tracking.deltas['old_status'], StockStatus.OK.value) + self.assertEqual( + tracking.deltas['old_status_logical'], StockStatus.OK.value + ) + self.assertEqual(tracking.deltas['status'], StockStatus.DAMAGED.value) + self.assertEqual( + tracking.deltas['status_logical'], StockStatus.DAMAGED.value + ) # Same test, but with one item unchanged items[0].set_status(StockStatus.ATTENTION.value) @@ -1837,6 +1857,68 @@ class StockItemTest(StockAPITestCase): tracking = item.tracking_info.last() self.assertEqual(tracking.tracking_type, StockHistoryCode.EDITED.value) + def test_set_custom_status(self): + """Test API endpoint for setting StockItem custom status.""" + url = reverse('api-stock-change-status') + + prt = Part.objects.first() + + # Number of items to create + N_ITEMS = 10 + + # Create a bunch of items + items = [ + StockItem.objects.create(part=prt, quantity=10) for _ in range(N_ITEMS) + ] + + for item in items: + item.refresh_from_db() + self.assertEqual(item.status, StockStatus.OK.value) + self.assertEqual(item.tracking_info.count(), 1) + + # Test tracking with custom status + # *from* standard *to* custom + data = { + 'items': [item.pk for item in items], + 'status': self.inspect_custom_status.key, + } + + self.post(url, data, expected_code=201) + + for item in items: + item.refresh_from_db() + self.assertEqual(item.status, self.inspect_custom_status.logical_key) + self.assertEqual(item.get_custom_status(), self.inspect_custom_status.key) + tracking = item.tracking_info.last() + self.assertEqual(tracking.deltas['old_status'], StockStatus.OK.value) + self.assertEqual( + tracking.deltas['old_status_logical'], StockStatus.OK.value + ) + self.assertEqual(tracking.deltas['status'], self.inspect_custom_status.key) + self.assertEqual( + tracking.deltas['status_logical'], + self.inspect_custom_status.logical_key, + ) + + # reverse case + # *from* custom *to* standard + data['status'] = StockStatus.OK.value + self.post(url, data, expected_code=201) + for item in items: + item.refresh_from_db() + self.assertEqual(item.status, StockStatus.OK.value) + self.assertIsNone(item.get_custom_status()) + tracking = item.tracking_info.last() + self.assertEqual( + tracking.deltas['old_status'], self.inspect_custom_status.key + ) + self.assertEqual( + tracking.deltas['old_status_logical'], + self.inspect_custom_status.logical_key, + ) + self.assertEqual(tracking.deltas['status'], StockStatus.OK.value) + self.assertEqual(tracking.deltas['status_logical'], StockStatus.OK.value) + class StocktakeTest(StockAPITestCase): """Series of tests for the Stocktake API.""" diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index 6ecfe6bb88..cf08aea6c7 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -30,7 +30,8 @@ interface RenderStatusLabelOptionsInterface { function renderStatusLabel( key: string | number, codes: StatusCodeListInterface, - options: RenderStatusLabelOptionsInterface = {} + options: RenderStatusLabelOptionsInterface = {}, + fallback_key: string | number | null = null ) { let text = null; let color = null; @@ -46,6 +47,19 @@ function renderStatusLabel( } } + if (!text && fallback_key !== null) { + // Handle fallback key (if provided) + for (const name in codes.values) { + const entry: StatusCodeInterface = codes.values[name]; + + if (entry?.key == fallback_key) { + text = entry.label; + color = entry.color; + break; + } + } + } + if (!text) { console.error( `ERR: renderStatusLabel could not find match for code ${key}` @@ -164,11 +178,13 @@ export function getStatusCodeLabel( export const StatusRenderer = ({ status, type, - options + options, + fallbackStatus }: { status: string | number; type: ModelType | string; options?: RenderStatusLabelOptionsInterface; + fallbackStatus?: string | number | null; }) => { const statusCodes = getStatusCodes(type); @@ -183,7 +199,7 @@ export const StatusRenderer = ({ return null; } - return renderStatusLabel(status, statusCodes, options); + return renderStatusLabel(status, statusCodes, options, fallbackStatus); }; /* diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 46b7c0ab1d..06713066d5 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -283,7 +283,8 @@ function BuildOutputFormRow({ {record.batch} {' '} diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 27ff0f1f3c..7dcd46e332 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -646,7 +646,8 @@ function StockOperationsRow({ {stockString} diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 3205d00ca5..6b39b83964 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -956,6 +956,7 @@ export default function StockDetail() { />, ) { key: 'status', details: deltas.status && - StatusRenderer({ status: deltas.status, type: ModelType.stockitem }) + StatusRenderer({ + status: deltas.status, + type: ModelType.stockitem, + fallbackStatus: deltas.status_logical + }) + }, + { + label: t`Old Status`, + key: 'old_status', + details: + deltas.old_status && + StatusRenderer({ + status: deltas.old_status, + type: ModelType.stockitem, + fallbackStatus: deltas.old_status_logical + }) }, { label: t`Quantity`,