2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-30 21:25:36 +00:00

Count to location (#12019)

* Add "location" field to StockCountSerializer

* Adjust location on stock count

* Add unit tests

* Add docs

* Update API and CHANGELOG
This commit is contained in:
Oliver
2026-05-27 23:41:40 +10:00
committed by GitHub
parent 33483a3824
commit a9549c2e07
7 changed files with 125 additions and 1 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 496
INVENTREE_API_VERSION = 497
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v497 -> 2026-05-27 : https://github.com/inventree/InvenTree/pull/12019
- Adds "location" field to StockCount API endpoint
v496 -> 2026-05-26 : https://github.com/inventree/InvenTree/pull/12011
- Add "creation_date" field to the StockItem API endpoint
+9
View File
@@ -2624,6 +2624,7 @@ class StockItem(
Keyword Arguments:
notes: Optional notes for the stocktake
status: Optionally adjust the stock status
location: Optionally set the stock location
"""
try:
count = Decimal(count)
@@ -2635,6 +2636,14 @@ class StockItem(
tracking_info = {}
location = kwargs.pop('location', None)
if location and location != self.location:
old_location = self.location
self.location = location
tracking_info['location'] = location.pk
tracking_info['old_location'] = old_location.pk if old_location else None
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and not self.compare_status(status):
@@ -1744,6 +1744,20 @@ class StockAdjustmentSerializer(serializers.Serializer):
class StockCountSerializer(StockAdjustmentSerializer):
"""Serializer for counting stock items."""
class Meta:
"""Metaclass options."""
fields = ['items', 'notes', 'location']
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.filter(structural=False),
many=False,
required=False,
allow_null=True,
label=_('Location'),
help_text=_('Set stock location for counted items (optional)'),
)
def save(self):
"""Count stock."""
request = self.context['request']
@@ -1751,6 +1765,7 @@ class StockCountSerializer(StockAdjustmentSerializer):
data = self.validated_data
items = data['items']
notes = data.get('notes', '')
location = data.get('location', None)
with transaction.atomic():
for item in items:
@@ -1764,6 +1779,9 @@ class StockCountSerializer(StockAdjustmentSerializer):
if field_value := item.get(field_name, None):
extra[field_name] = field_value
if location is not None:
extra['location'] = location
stock_item.stocktake(quantity, request.user, notes=notes, **extra)
+82
View File
@@ -2253,6 +2253,88 @@ class StocktakeTest(StockAPITestCase):
status_code=status.HTTP_400_BAD_REQUEST,
)
def test_count_with_location(self):
"""Test that the stock count endpoint correctly handles the optional location field."""
url = reverse('api-stock-count')
# Stock item pk=1234 starts at location 5; pk=1 starts at location 3
item_a = StockItem.objects.get(pk=1234)
item_b = StockItem.objects.get(pk=1)
self.assertEqual(item_a.location.pk, 5)
self.assertEqual(item_b.location.pk, 3)
# --- location is updated when provided (single item) ---
response = self.post(
url,
{'items': [{'pk': item_a.pk, 'quantity': 10}], 'location': 1},
expected_code=201,
)
self.assertEqual(response.data['items'][0]['pk'], item_a.pk)
item_a.refresh_from_db()
self.assertEqual(item_a.location.pk, 1)
# Tracking entry records the location change
entry = StockItemTracking.objects.filter(
item=item_a, tracking_type=StockHistoryCode.STOCK_COUNT
).latest('date')
self.assertEqual(entry.deltas.get('location'), 1)
self.assertEqual(entry.deltas.get('old_location'), 5)
# --- location is updated for multiple items simultaneously ---
response = self.post(
url,
{
'items': [
{'pk': item_a.pk, 'quantity': 5},
{'pk': item_b.pk, 'quantity': 20},
],
'location': 2,
},
expected_code=201,
)
self.assertEqual(len(response.data['items']), 2)
item_a.refresh_from_db()
item_b.refresh_from_db()
self.assertEqual(item_a.location.pk, 2)
self.assertEqual(item_b.location.pk, 2)
# Both items have a tracking entry with the new location
for item, old_loc in [(item_a, 1), (item_b, 3)]:
entry = StockItemTracking.objects.filter(
item=item, tracking_type=StockHistoryCode.STOCK_COUNT
).latest('date')
self.assertEqual(entry.deltas.get('location'), 2)
self.assertEqual(entry.deltas.get('old_location'), old_loc)
# --- location is unchanged when not provided ---
response = self.post(
url, {'items': [{'pk': item_a.pk, 'quantity': 7}]}, expected_code=201
)
item_a.refresh_from_db()
# Location should still be 2 (unchanged from the previous count)
self.assertEqual(item_a.location.pk, 2)
# Tracking entry has no location delta when location was not provided
entry = StockItemTracking.objects.filter(
item=item_a, tracking_type=StockHistoryCode.STOCK_COUNT
).latest('date')
self.assertNotIn('location', entry.deltas)
self.assertNotIn('old_location', entry.deltas)
# --- structural location is rejected ---
structural = StockLocation.objects.create(name='Structural', structural=True)
response = self.post(
url,
{'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk},
expected_code=400,
)
self.assertIn('does not exist', str(response.data['location']))
class StockItemDeletionTest(StockAPITestCase):
"""Tests for stock item deletion via the API."""
+9
View File
@@ -967,6 +967,9 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
const initialValue = mapAdjustmentItems(items);
// Extract all location values from the items
const locations = [...new Set(items.map((item) => item.location))];
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
@@ -990,6 +993,12 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
{ title: t`Actions` }
]
},
location: {
value: locations.length === 1 ? locations[0] : undefined,
filters: {
structural: false
}
},
notes: {}
};