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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 125 additions and 1 deletions

View File

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation.
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
- [#11920](https://github.com/inventree/InvenTree/pull/11920) adds support for renaming attachments after they have been uploaded. This includes both backend and frontend changes, allowing users to rename attachments via the API or through the UI.

View File

@ -32,6 +32,8 @@ Remove parts from a stock item record - for example taking parts from stock for
Count stock items (stocktake) to record the number of items in stock at a given point of time. The quantity for each part is pre-filled with the current quantity based on stock item history.
An optional **Location** field allows all counted items to be moved to a new location in the same operation. If left blank, each item retains its current location.
{{ image("stock/stock_count.png", "Stock Count") }}
### Merge Stock

View File

@ -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

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):

View File

@ -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)

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."""

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: {}
};