Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue11460

This commit is contained in:
Matthias Mair 2026-05-27 23:57:12 +02:00
commit 4b4a698d33
No known key found for this signature in database
GPG Key ID: A593429DDA23B66A
14 changed files with 256 additions and 108 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

@ -550,29 +550,31 @@ def increment_serial_number(serial, part=None):
incremented value, or None if incrementing could not be performed.
"""
from InvenTree.exceptions import log_error
from InvenTree.ready import isReadOnlyCommand
from plugin import PluginMixinEnum, registry
# Ensure we start with a string value
if serial is not None:
serial = str(serial).strip()
# First, let any plugins attempt to increment the serial number
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
if not hasattr(plugin, 'increment_serial_number'):
continue
if not isReadOnlyCommand():
# First, let any plugins attempt to increment the serial number
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
if not hasattr(plugin, 'increment_serial_number'):
continue
signature = inspect.signature(plugin.increment_serial_number)
signature = inspect.signature(plugin.increment_serial_number)
# Note: 2024-08-21 - The 'part' parameter has been added to the signature
if 'part' in signature.parameters:
result = plugin.increment_serial_number(serial, part=part)
else:
result = plugin.increment_serial_number(serial)
if result is not None:
return str(result)
except Exception:
log_error('increment_serial_number', plugin=plugin.slug)
# Note: 2024-08-21 - The 'part' parameter has been added to the signature
if 'part' in signature.parameters:
result = plugin.increment_serial_number(serial, part=part)
else:
result = plugin.increment_serial_number(serial)
if result is not None:
return str(result)
except Exception:
log_error('increment_serial_number', plugin=plugin.slug)
# If we get to here, no plugins were able to "increment" the provided serial value
# Attempt to perform increment according to some basic rules

View File

@ -92,10 +92,23 @@ class PluginValidationMixin(DiffMixin):
Any model class which inherits from this mixin will be exposed to the plugin validation system.
"""
def should_plugin_validate(self):
"""Return True if this model instance should be validated by plugins.
The default implementation returns True, but this can be overridden in the implementing class if required.
"""
from InvenTree.ready import isReadOnlyCommand
# Prevent plugin validation when importing or exporting data
return not isReadOnlyCommand()
def run_plugin_validation(self):
"""Throw this model against the plugin validation interface."""
from plugin import PluginMixinEnum, registry
if not self.should_plugin_validate():
return
deltas = self.get_field_deltas()
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
@ -139,15 +152,16 @@ class PluginValidationMixin(DiffMixin):
from InvenTree.exceptions import log_error
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
plugin.validate_model_deletion(self)
except ValidationError as e:
# Plugin might raise a ValidationError to prevent deletion
raise e
except Exception:
log_error('validate_model_deletion', plugin=plugin.slug)
continue
if self.should_plugin_validate():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
plugin.validate_model_deletion(self)
except ValidationError as e:
# Plugin might raise a ValidationError to prevent deletion
raise e
except Exception:
log_error('validate_model_deletion', plugin=plugin.slug)
continue
super().delete(*args, **kwargs)

View File

@ -2875,6 +2875,10 @@ class Parameter(
except ValidationError as e:
raise ValidationError({'data': e.message})
if InvenTree.ready.isReadOnlyCommand():
# Skip plugin validation checks during read-only management commands
return
# Finally, run custom validation checks (via plugins)
from plugin import PluginMixinEnum, registry

View File

@ -724,19 +724,21 @@ class Part(
"""
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
# Run the name through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
# Skip plugin validation checks during read-only management commands
if not InvenTree.ready.isReadOnlyCommand():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
# Run the name through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
try:
result = plugin.validate_part_name(self.name, self)
if result:
return
except ValidationError as exc:
if raise_error:
raise ValidationError({'name': exc.message})
except Exception:
log_error('validate_part_name', plugin=plugin.slug)
try:
result = plugin.validate_part_name(self.name, self)
if result:
return
except ValidationError as exc:
if raise_error:
raise ValidationError({'name': exc.message})
except Exception:
log_error('validate_part_name', plugin=plugin.slug)
def validate_ipn(self, raise_error=True):
"""Ensure that the IPN (internal part number) is valid for this Part".
@ -746,18 +748,20 @@ class Part(
"""
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
result = plugin.validate_part_ipn(self.IPN, self)
# Skip plugin validation checks during read-only management commands
if not InvenTree.ready.isReadOnlyCommand():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
result = plugin.validate_part_ipn(self.IPN, self)
if result:
# A "true" result force skips any subsequent checks
break
except ValidationError as exc:
if raise_error:
raise ValidationError({'IPN': exc.message})
except Exception:
log_error('validate_part_ipn', plugin=plugin.slug)
if result:
# A "true" result force skips any subsequent checks
break
except ValidationError as exc:
if raise_error:
raise ValidationError({'IPN': exc.message})
except Exception:
log_error('validate_part_ipn', plugin=plugin.slug)
# If we get to here, none of the plugins have raised an error
pattern = get_global_setting('PART_IPN_REGEX', '', create=False).strip()
@ -835,40 +839,41 @@ class Part(
Raises:
ValidationError if serial number is invalid and raise_error = True
"""
serial = str(serial).strip()
# First, throw the serial number against each of the loaded validation plugins
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
# Run the serial number through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
serial = str(serial).strip()
try:
result = False
if not InvenTree.ready.isReadOnlyCommand():
# First, throw the serial number against each of the loaded validation plugins
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
# Run the serial number through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
if hasattr(plugin, 'validate_serial_number'):
signature = inspect.signature(plugin.validate_serial_number)
try:
result = False
if 'stock_item' in signature.parameters:
# 2024-08-21: New method signature accepts a 'stock_item' parameter
result = plugin.validate_serial_number(
serial, self, stock_item=stock_item
)
if hasattr(plugin, 'validate_serial_number'):
signature = inspect.signature(plugin.validate_serial_number)
if 'stock_item' in signature.parameters:
# 2024-08-21: New method signature accepts a 'stock_item' parameter
result = plugin.validate_serial_number(
serial, self, stock_item=stock_item
)
else:
# Old method signature - does not accept a 'stock_item' parameter
result = plugin.validate_serial_number(serial, self)
if result is True:
return True
except ValidationError as exc:
if raise_error:
# Re-throw the error
raise exc
else:
# Old method signature - does not accept a 'stock_item' parameter
result = plugin.validate_serial_number(serial, self)
if result is True:
return True
except ValidationError as exc:
if raise_error:
# Re-throw the error
raise exc
else:
return False
except Exception:
log_error('validate_serial_number', plugin=plugin.slug)
return False
except Exception:
log_error('validate_serial_number', plugin=plugin.slug)
"""
If we are here, none of the loaded plugins (if any) threw an error or exited early
@ -960,7 +965,7 @@ class Part(
"""
from plugin import PluginMixinEnum, registry
if allow_plugins:
if allow_plugins and not InvenTree.ready.isReadOnlyCommand():
# Check with plugin system
# If any plugin returns a non-null result, that takes priority
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):

View File

@ -779,24 +779,24 @@ class StockItem(
# First, let any plugins convert this serial number to an integer value
# If a non-null value is returned (by any plugin) we will use that
if not InvenTree.ready.isReadOnlyCommand():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
serial_int = plugin.convert_serial_to_int(serial)
except Exception:
InvenTree.exceptions.log_error(
'convert_serial_to_int', plugin=plugin.slug
)
serial_int = None
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
serial_int = plugin.convert_serial_to_int(serial)
except Exception:
InvenTree.exceptions.log_error(
'convert_serial_to_int', plugin=plugin.slug
)
serial_int = None
# Save the first returned result
if serial_int is not None:
# Ensure that it is clipped within a range allowed in the database schema
clip = 0x7FFFFFFF
serial_int = abs(serial_int)
serial_int = min(serial_int, clip)
# Return the first non-null value
return serial_int
# Save the first returned result
if serial_int is not None:
# Ensure that it is clipped within a range allowed in the database schema
clip = 0x7FFFFFFF
serial_int = abs(serial_int)
serial_int = min(serial_int, clip)
# Return the first non-null value
return serial_int
# None of the plugins provided a valid integer value
if serial not in [None, '']:
@ -922,15 +922,16 @@ class StockItem(
"""
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
plugin.validate_batch_code(self.batch, self)
except ValidationError as exc:
raise ValidationError({'batch': exc.message})
except Exception:
InvenTree.exceptions.log_error(
'validate_batch_code', plugin=plugin.slug
)
if not InvenTree.ready.isReadOnlyCommand():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
plugin.validate_batch_code(self.batch, self)
except ValidationError as exc:
raise ValidationError({'batch': exc.message})
except Exception:
InvenTree.exceptions.log_error(
'validate_batch_code', plugin=plugin.slug
)
def clean(self):
"""Validate the StockItem object (separate to field validation).
@ -2623,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)
@ -2634,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: {}
};

View File

@ -814,9 +814,9 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
}, [tableProps.onCellClick, tableProps.onRowClick, tableProps.modelType]);
// When sticky headers are enabled, we adjust the maximum viewport height,
// based on the number of records being displayed (up to a maximum of 60vh)
// based on the number of records being displayed (up to a maximum of 80vh)
const autoHeight = useMemo(() => {
const rows = Math.max(10, Math.min(tableState.records.length, 60));
const rows = Math.min(80, 6 * Math.max(tableState.records.length, 3));
return `${rows}vh`;
}, [tableState.records]);

View File

@ -261,7 +261,7 @@ export default function PluginListTable() {
const [pluginPackage, setPluginPackage] = useState<string>('');
const activatePluginModal = useEditApiFormModal({
title: t`Activate Plugin`,
title: activate ? t`Activate Plugin` : t`Deactivate Plugin`,
url: ApiEndpoints.plugin_activate,
pathParams: { key: selectedPluginKey },
preFormContent: activateModalContent,

View File

@ -290,8 +290,6 @@ test('Parts - BOM Validation', async ({ browser }) => {
// Edit line item, to ensure BOM is not valid
const cell = await page.getByRole('cell', { name: 'paint', exact: true });
// await cell.click({ button: 'right' });
// await page.getByRole('button', { name: 'Edit', exact: true }).click();
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();