2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-02 03:14:56 +00:00

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 <code@mjmair.com>
This commit is contained in:
Jacob Felknor
2026-01-27 00:44:28 -07:00
committed by GitHub
parent 67c67a1650
commit df90934f0c
8 changed files with 224 additions and 22 deletions

View File

@@ -795,12 +795,28 @@ class StockItem(
try: try:
old = StockItem.objects.get(pk=self.pk) old = StockItem.objects.get(pk=self.pk)
old_custom_status = old.get_custom_status()
custom_status = self.get_custom_status()
deltas = {} deltas = {}
# Status changed? # Status changed?
if old.status != self.status: 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: if add_note and len(deltas) > 0:
self.add_tracking_entry( self.add_tracking_entry(
@@ -1446,8 +1462,17 @@ class StockItem(
if status := kwargs.pop('status', None): if status := kwargs.pop('status', None):
if not item.compare_status(status): if not item.compare_status(status):
old_custom_status = item.get_custom_status()
old_status_logical = item.status
item.set_status(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() item.save()
@@ -2268,8 +2293,26 @@ class StockItem(
# Optional fields which can be supplied in a 'move' call # Optional fields which can be supplied in a 'move' call
for field in StockItem.optional_transfer_fields(): for field in StockItem.optional_transfer_fields():
if field in kwargs: if field in kwargs:
setattr(new_stock, field, kwargs[field]) # handle specific case for status deltas
deltas[field] = kwargs[field] 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) new_stock.save(add_note=False)
@@ -2385,8 +2428,15 @@ class StockItem(
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and not self.compare_status(status): if status and not self.compare_status(status):
old_custom_status = self.get_custom_status()
old_status_logical = self.status
self.set_status(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 # Optional fields which can be supplied in a 'move' call
for field in StockItem.optional_transfer_fields(): for field in StockItem.optional_transfer_fields():
@@ -2437,7 +2487,7 @@ class StockItem(
return False return False
self.save() self.save(add_note=False)
trigger_event( trigger_event(
StockEvents.ITEM_QUANTITY_UPDATED, id=self.id, quantity=float(self.quantity) 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) status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and not self.compare_status(status): if status and not self.compare_status(status):
old_custom_status = self.get_custom_status()
old_status_logical = self.status
self.set_status(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): if self.updateQuantity(count):
tracking_info['quantity'] = float(count) tracking_info['quantity'] = float(count)
@@ -2533,8 +2590,15 @@ class StockItem(
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and not self.compare_status(status): if status and not self.compare_status(status):
old_custom_status = self.get_custom_status()
old_status_logical = self.status
self.set_status(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): if self.updateQuantity(self.quantity + quantity):
tracking_info['added'] = float(quantity) tracking_info['added'] = float(quantity)
@@ -2587,8 +2651,15 @@ class StockItem(
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and not self.compare_status(status): if status and not self.compare_status(status):
old_custom_status = self.get_custom_status()
old_status_logical = self.status
self.set_status(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): if self.updateQuantity(self.quantity - quantity):
deltas['removed'] = float(quantity) deltas['removed'] = float(quantity)

View File

@@ -463,12 +463,16 @@ class StockItemSerializer(
status_custom_key = validated_data.pop('status_custom_key', None) status_custom_key = validated_data.pop('status_custom_key', None)
status = validated_data.pop('status', None) status = validated_data.pop('status', None)
instance = super().update(instance, validated_data=validated_data)
if status_code := status_custom_key or status: if status_code := status_custom_key or status:
if not instance.compare_status(status_code): # avoid a second .save() call and perform both status updates at once (to support `old_status` in tracking event)
instance.set_status(status_code) # by setting the values in validated_data as computed by set_status()
instance.save() 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 return instance
@@ -1053,8 +1057,6 @@ class StockChangeStatusSerializer(serializers.Serializer):
transaction_notes = [] transaction_notes = []
deltas = {'status': status}
now = InvenTree.helpers.current_time() now = InvenTree.helpers.current_time()
# Instead of performing database updates for each item, # 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: if status == custom_status or custom_status is None:
continue 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.set_status(status, custom_values=custom_status_codes)
item.save(add_note=False) 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 # Create a new transaction note for each item
transaction_notes.append( transaction_notes.append(
StockItemTracking( StockItemTracking(

View File

@@ -1315,6 +1315,17 @@ class StockItemTest(StockAPITestCase):
StockLocation.objects.create(name='B', description='location b', parent=top) StockLocation.objects.create(name='B', description='location b', parent=top)
StockLocation.objects.create(name='C', description='location c', 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): def test_create_default_location(self):
"""Test the default location functionality, if a 'location' is not specified in the creation request.""" """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 # The part 'R_4K7_0603' (pk=4) has a default location specified
@@ -1820,6 +1831,15 @@ class StockItemTest(StockAPITestCase):
item.refresh_from_db() item.refresh_from_db()
self.assertEqual(item.status, StockStatus.DAMAGED.value) self.assertEqual(item.status, StockStatus.DAMAGED.value)
self.assertEqual(item.tracking_info.count(), 2) 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 # Same test, but with one item unchanged
items[0].set_status(StockStatus.ATTENTION.value) items[0].set_status(StockStatus.ATTENTION.value)
@@ -1837,6 +1857,68 @@ class StockItemTest(StockAPITestCase):
tracking = item.tracking_info.last() tracking = item.tracking_info.last()
self.assertEqual(tracking.tracking_type, StockHistoryCode.EDITED.value) 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): class StocktakeTest(StockAPITestCase):
"""Series of tests for the Stocktake API.""" """Series of tests for the Stocktake API."""

View File

@@ -30,7 +30,8 @@ interface RenderStatusLabelOptionsInterface {
function renderStatusLabel( function renderStatusLabel(
key: string | number, key: string | number,
codes: StatusCodeListInterface, codes: StatusCodeListInterface,
options: RenderStatusLabelOptionsInterface = {} options: RenderStatusLabelOptionsInterface = {},
fallback_key: string | number | null = null
) { ) {
let text = null; let text = null;
let color = 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) { if (!text) {
console.error( console.error(
`ERR: renderStatusLabel could not find match for code ${key}` `ERR: renderStatusLabel could not find match for code ${key}`
@@ -164,11 +178,13 @@ export function getStatusCodeLabel(
export const StatusRenderer = ({ export const StatusRenderer = ({
status, status,
type, type,
options options,
fallbackStatus
}: { }: {
status: string | number; status: string | number;
type: ModelType | string; type: ModelType | string;
options?: RenderStatusLabelOptionsInterface; options?: RenderStatusLabelOptionsInterface;
fallbackStatus?: string | number | null;
}) => { }) => {
const statusCodes = getStatusCodes(type); const statusCodes = getStatusCodes(type);
@@ -183,7 +199,7 @@ export const StatusRenderer = ({
return null; return null;
} }
return renderStatusLabel(status, statusCodes, options); return renderStatusLabel(status, statusCodes, options, fallbackStatus);
}; };
/* /*

View File

@@ -283,7 +283,8 @@ function BuildOutputFormRow({
<Table.Td>{record.batch}</Table.Td> <Table.Td>{record.batch}</Table.Td>
<Table.Td> <Table.Td>
<StatusRenderer <StatusRenderer
status={record.status} status={record.custom_status_key || record.status}
fallbackStatus={record.status}
type={ModelType.stockitem} type={ModelType.stockitem}
/>{' '} />{' '}
</Table.Td> </Table.Td>

View File

@@ -646,7 +646,8 @@ function StockOperationsRow({
<Group grow justify='space-between' wrap='nowrap'> <Group grow justify='space-between' wrap='nowrap'>
<Text>{stockString}</Text> <Text>{stockString}</Text>
<StatusRenderer <StatusRenderer
status={record.status_custom_key} status={record.status_custom_key || record.status}
fallbackStatus={record.status}
type={ModelType.stockitem} type={ModelType.stockitem}
/> />
</Group> </Group>

View File

@@ -956,6 +956,7 @@ export default function StockDetail() {
/>, />,
<StatusRenderer <StatusRenderer
status={stockitem.status_custom_key || stockitem.status} status={stockitem.status_custom_key || stockitem.status}
fallbackStatus={stockitem.status}
type={ModelType.stockitem} type={ModelType.stockitem}
options={{ options={{
size: 'lg' size: 'lg'

View File

@@ -66,7 +66,22 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
key: 'status', key: 'status',
details: details:
deltas.status && 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`, label: t`Quantity`,