mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-28 03:49:20 +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:
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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.
|
- [#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.
|
- [#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.
|
- [#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.
|
||||||
|
|||||||
@@ -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.
|
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") }}
|
{{ image("stock/stock_count.png", "Stock Count") }}
|
||||||
|
|
||||||
### Merge Stock
|
### Merge Stock
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v496 -> 2026-05-26 : https://github.com/inventree/InvenTree/pull/12011
|
||||||
- Add "creation_date" field to the StockItem API endpoint
|
- Add "creation_date" field to the StockItem API endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -2624,6 +2624,7 @@ class StockItem(
|
|||||||
Keyword Arguments:
|
Keyword Arguments:
|
||||||
notes: Optional notes for the stocktake
|
notes: Optional notes for the stocktake
|
||||||
status: Optionally adjust the stock status
|
status: Optionally adjust the stock status
|
||||||
|
location: Optionally set the stock location
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
count = Decimal(count)
|
count = Decimal(count)
|
||||||
@@ -2635,6 +2636,14 @@ class StockItem(
|
|||||||
|
|
||||||
tracking_info = {}
|
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)
|
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):
|
||||||
|
|||||||
@@ -1744,6 +1744,20 @@ class StockAdjustmentSerializer(serializers.Serializer):
|
|||||||
class StockCountSerializer(StockAdjustmentSerializer):
|
class StockCountSerializer(StockAdjustmentSerializer):
|
||||||
"""Serializer for counting stock items."""
|
"""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):
|
def save(self):
|
||||||
"""Count stock."""
|
"""Count stock."""
|
||||||
request = self.context['request']
|
request = self.context['request']
|
||||||
@@ -1751,6 +1765,7 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
items = data['items']
|
items = data['items']
|
||||||
notes = data.get('notes', '')
|
notes = data.get('notes', '')
|
||||||
|
location = data.get('location', None)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -1764,6 +1779,9 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||||||
if field_value := item.get(field_name, None):
|
if field_value := item.get(field_name, None):
|
||||||
extra[field_name] = field_value
|
extra[field_name] = field_value
|
||||||
|
|
||||||
|
if location is not None:
|
||||||
|
extra['location'] = location
|
||||||
|
|
||||||
stock_item.stocktake(quantity, request.user, notes=notes, **extra)
|
stock_item.stocktake(quantity, request.user, notes=notes, **extra)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2253,6 +2253,88 @@ class StocktakeTest(StockAPITestCase):
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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):
|
class StockItemDeletionTest(StockAPITestCase):
|
||||||
"""Tests for stock item deletion via the API."""
|
"""Tests for stock item deletion via the API."""
|
||||||
|
|||||||
@@ -967,6 +967,9 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
|
|||||||
|
|
||||||
const initialValue = mapAdjustmentItems(items);
|
const initialValue = mapAdjustmentItems(items);
|
||||||
|
|
||||||
|
// Extract all location values from the items
|
||||||
|
const locations = [...new Set(items.map((item) => item.location))];
|
||||||
|
|
||||||
const fields: ApiFormFieldSet = {
|
const fields: ApiFormFieldSet = {
|
||||||
items: {
|
items: {
|
||||||
field_type: 'table',
|
field_type: 'table',
|
||||||
@@ -990,6 +993,12 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
|
|||||||
{ title: t`Actions` }
|
{ title: t`Actions` }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
location: {
|
||||||
|
value: locations.length === 1 ? locations[0] : undefined,
|
||||||
|
filters: {
|
||||||
|
structural: false
|
||||||
|
}
|
||||||
|
},
|
||||||
notes: {}
|
notes: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user