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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
@@ -283,7 +283,8 @@ function BuildOutputFormRow({
|
||||
<Table.Td>{record.batch}</Table.Td>
|
||||
<Table.Td>
|
||||
<StatusRenderer
|
||||
status={record.status}
|
||||
status={record.custom_status_key || record.status}
|
||||
fallbackStatus={record.status}
|
||||
type={ModelType.stockitem}
|
||||
/>{' '}
|
||||
</Table.Td>
|
||||
|
||||
@@ -646,7 +646,8 @@ function StockOperationsRow({
|
||||
<Group grow justify='space-between' wrap='nowrap'>
|
||||
<Text>{stockString}</Text>
|
||||
<StatusRenderer
|
||||
status={record.status_custom_key}
|
||||
status={record.status_custom_key || record.status}
|
||||
fallbackStatus={record.status}
|
||||
type={ModelType.stockitem}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -956,6 +956,7 @@ export default function StockDetail() {
|
||||
/>,
|
||||
<StatusRenderer
|
||||
status={stockitem.status_custom_key || stockitem.status}
|
||||
fallbackStatus={stockitem.status}
|
||||
type={ModelType.stockitem}
|
||||
options={{
|
||||
size: 'lg'
|
||||
|
||||
@@ -66,7 +66,22 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
|
||||
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`,
|
||||
|
||||
Reference in New Issue
Block a user