2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-12 04:28:45 +00:00

Maximum stock level (#11914)

* Add "maximum_stock" field

* Add API filter

* Update API version

* Update CHANGELOG

* Frontend updates

* Add dashboard widget

* docs

* Add API tests
This commit is contained in:
Oliver
2026-05-10 20:55:05 +10:00
committed by GitHub
parent bb2a72a6fb
commit 35aa4d33d3
14 changed files with 181 additions and 7 deletions
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#11914](https://github.com/inventree/InvenTree/pull/11914) adds a "maximum_stock" field to the Part model, allowing users to specify a maximum preferred stock level for each part. This is used in conjunction with the existing "minimum_stock" field to allow users to define a preferred stock range for each part. The "high_stock" filter has also been added to the Part API endpoint, allowing users to filter parts which are above their maximum stock level.
- [#11631](https://github.com/inventree/InvenTree/pull/11631) adds "raw_amount" field to the BomItem model, allowing BOM quantities to account for the units of measure of the underlying part.
- [#11872](https://github.com/inventree/InvenTree/pull/11872) adds a global setting to allow or disallow the deletion of serialized stock items.
- [#11861](https://github.com/inventree/InvenTree/pull/11861) adds support for bulk-replacing a component in multiple BOMs simultaneously
+14 -1
View File
@@ -6,6 +6,18 @@ title: Parts
The *Part* is the core element of the InvenTree ecosystem. A Part object is the archetype of any stock item in your inventory. Parts are arranged in hierarchical categories which are used to organize and filter parts by function.
## Part Stock
Each part can have multiple [stock items](../stock/index.md) associated with it, which represent the physical quantity of that part in various locations. The total stock level for a given part is the sum of all stock items associated with that part.
### Minimum Stock
A part may have a specified "minimum stock" level. This is a user-defined value which indicates the minimum quantity of that part which should be kept in stock at all times. If the total stock level for a given part falls below the minimum stock level, the part is flagged as "low stock" and can be easily identified in the interface.
### Maximum Stock
A part may also have a specified "maximum stock" level. This is a user-defined value which indicates the maximum quantity of that part which should be kept in stock at all times. If the total stock level for a given part exceeds the maximum stock level, the part is flagged as "overstocked" and can be easily identified in the interface.
## Part Category
Part categories are very flexible and can be easily arranged to match a particular user requirement. Each part category displays a list of all parts *under* that given category. This means that any part belonging to a particular category, or belonging to a sub-category, will be displayed.
@@ -27,7 +39,7 @@ Clicking on the part name links to the [*Part Detail*](./views.md) view.
## Part Attributes
Each *Part* defined in the database provides a number of different attributes which determine how that part can be used. Configuring these attributes for a given part will impact the available functions that can be perform on (or using) that part).
Each *Part* defined in the database provides a number of different attributes which determine how that part can be used. Configuring these attributes for a given part will impact the available functions that can be perform on (or using) that part.
### Virtual
@@ -75,6 +87,7 @@ A [Purchase Order](../purchasing/purchase_order.md) allows parts to be ordered f
If a part is designated as *Salable* it can be sold to external customers. Setting this flag allows parts to be added to sales orders.
## Locked Parts
Parts can be locked to prevent them from being modified. This is useful for parts which are in production and should not be changed. The following restrictions apply to parts which are locked:
@@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 485
INVENTREE_API_VERSION = 486
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v486 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11914
- Adds "maximum_stock" field to the Part model and associated API endpoints
- Adds "high_stock" filter to the Part API endpoint to filter parts which are above their maximum stock level
v485 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11631
- Adds "raw_amount" field to the BomItem API endpoint
+17
View File
@@ -729,6 +729,23 @@ class PartFilter(FilterSet):
# Filter items which have an 'in_stock' level higher than 'minimum_stock'
return queryset.filter(Q(total_in_stock__gte=F('minimum_stock')))
high_stock = rest_filters.BooleanFilter(
label='High stock', method='filter_high_stock'
)
def filter_high_stock(self, queryset, name, value):
"""Filter by "high stock" status."""
if str2bool(value):
# Ignore any parts which do not have a specified 'maximum_stock' level
# Filter items which have an 'in_stock' level higher than 'maximum_stock'
return queryset.exclude(maximum_stock=0).filter(
Q(total_in_stock__gt=F('maximum_stock'))
)
# Filter items which have an 'in_stock' level lower than 'maximum_stock'
return queryset.filter(
Q(total_in_stock__lte=F('maximum_stock')) | Q(maximum_stock=0)
).distinct()
# has_stock filter
has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
@@ -0,0 +1,26 @@
# Generated by Django 5.2.13 on 2026-05-10 06:02
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("part", "0149_bomitem_raw_amount"),
]
operations = [
migrations.AddField(
model_name="part",
name="maximum_stock",
field=models.DecimalField(
decimal_places=6,
default=0,
help_text="Maximum allowed stock level",
max_digits=19,
validators=[django.core.validators.MinValueValidator(0)],
verbose_name="Maximum Stock",
),
),
]
+10
View File
@@ -491,6 +491,7 @@ class Part(
default_location: Where the item is normally stored (may be null)
default_expiry: The default expiry duration for any StockItem instances of this part
minimum_stock: Minimum preferred quantity to keep in stock
maximum_stock: Maximum preferred quantity to keep in stock
units: Units of measure for this part (default='pcs')
salable: Can this part be sold to customers?
assembly: Can this part be build from other parts?
@@ -1232,6 +1233,15 @@ class Part(
help_text=_('Minimum allowed stock level'),
)
maximum_stock = models.DecimalField(
max_digits=19,
decimal_places=6,
default=0,
validators=[MinValueValidator(0)],
verbose_name=_('Maximum Stock'),
help_text=_('Maximum allowed stock level'),
)
units = models.CharField(
max_length=20,
default='',
@@ -600,6 +600,7 @@ class PartSerializer(
'link',
'locked',
'minimum_stock',
'maximum_stock',
'name',
'notes',
'parameters',
@@ -901,6 +902,10 @@ class PartSerializer(
required=False, label=_('Minimum Stock'), default=0
)
maximum_stock = serializers.FloatField(
required=False, label=_('Maximum Stock'), default=0
)
image = InvenTree.serializers.InvenTreeImageSerializerField(
required=False, allow_null=True
)
+52
View File
@@ -2649,6 +2649,58 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['building'], 55)
self.assertEqual(data['scheduled_to_build'], 32)
def test_low_stock(self):
"""Test the 'low_stock' filter."""
part = Part.objects.create(
name='Low Stock Part',
description='A part which is low on stock',
category=PartCategory.objects.get(pk=1),
minimum_stock=10,
)
response = self.get(
reverse('api-part-list'), {'low_stock': True}, expected_code=200
)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], part.pk)
StockItem.objects.create(part=part, quantity=20)
response = self.get(
reverse('api-part-list'), {'low_stock': True}, expected_code=200
)
# No results should be returned, as the part is no longer low on stock
self.assertEqual(len(response.data), 0)
def test_high_stock(self):
"""Test the 'high_stock' filter."""
part = Part.objects.create(
name='High Stock Part',
description='A part which is high on stock',
category=PartCategory.objects.get(pk=1),
)
StockItem.objects.create(part=part, quantity=100)
response = self.get(
reverse('api-part-list'), {'high_stock': True}, expected_code=200
)
self.assertEqual(len(response.data), 0)
# Set a "maximum stock" threshold for the part
part.maximum_stock = 50
part.save()
response = self.get(
reverse('api-part-list'), {'high_stock': True}, expected_code=200
)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], part.pk)
class BomItemTest(InvenTreeAPITestCase):
"""Unit tests for the BomItem API."""
@@ -70,6 +70,17 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
virtual: false
}
}),
QueryCountDashboardWidget({
title: t`High Stock`,
label: 'hgh-stk',
description: t`Show the number of parts which have excess stock`,
modelType: ModelType.part,
params: {
active: true,
high_stock: true,
virtual: false
}
}),
QueryCountDashboardWidget({
title: t`Required for Build Orders`,
label: 'bld-req',
@@ -35,6 +35,10 @@ export function RenderPart(
} else if (stock != null) {
badgeText = `${t`Stock`}: ${formatDecimal(stock)}`;
badgeColor = instance.minimum_stock > stock ? 'yellow' : 'green';
if (instance.maximum_stock > 0 && stock > instance.maximum_stock) {
badgeColor = 'teal';
}
}
const extra: ReactNode[] = [];
+1
View File
@@ -57,6 +57,7 @@ export function usePartFields({
},
default_expiry: {},
minimum_stock: {},
maximum_stock: {},
responsible: {
filters: {
is_active: true
+1
View File
@@ -120,6 +120,7 @@ const icons: InvenTreeIconType = {
unallocated_stock: IconPackage,
total_in_stock: IconPackages,
minimum_stock: IconFlag,
maximum_stock: IconFlag,
allocated_to_build_orders: IconTool,
allocated_to_sales_orders: IconTruck,
can_build: IconTools,
+19 -5
View File
@@ -454,6 +454,13 @@ export default function PartDetail() {
unit: part.units,
label: t`Minimum Stock`,
hidden: part.minimum_stock <= 0
},
{
type: 'number',
name: 'maximum_stock',
unit: part.units,
label: t`Maximum Stock`,
hidden: part.maximum_stock <= 0
}
];
@@ -875,14 +882,21 @@ export default function PartDetail() {
const shortfall = Math.max(required - partRequirements.total_stock, 0);
let stockColor = 'green';
if (partRequirements.total_stock <= part.minimum_stock) {
stockColor = 'orange';
} else if (
part.maximum_stock > 0 &&
partRequirements.total_stock > part.maximum_stock
) {
stockColor = 'teal';
}
return [
<DetailsBadge
label={`${t`In Stock`}: ${formatDecimal(partRequirements.total_stock)}`}
color={
partRequirements.total_stock >= part.minimum_stock
? 'green'
: 'orange'
}
color={stockColor}
visible={!part.virtual && partRequirements.total_stock > 0}
key='in_stock'
/>,
@@ -97,6 +97,7 @@ function partTableColumns(): TableColumn[] {
(record?.allocated_to_sales_orders ?? 0);
const available = Math.max(0, stock - allocated);
const min_stock = record?.minimum_stock ?? 0;
const max_stock = record?.maximum_stock ?? 0;
let text = String(formatDecimal(stock));
@@ -112,6 +113,14 @@ function partTableColumns(): TableColumn[] {
color = 'orange';
}
if (max_stock > 0 && stock > max_stock) {
extra.push(
<Text key='max-stock' c='teal'>
{`${t`Maximum stock`}: ${formatDecimal(max_stock)}`}
</Text>
);
}
if (record.ordering > 0) {
extra.push(
<Text key='on-order'>{`${t`On Order`}: ${formatDecimal(record.ordering)}`}</Text>
@@ -273,6 +282,12 @@ function partTableFilters(): TableFilter[] {
description: t`Filter by parts which have low stock`,
type: 'boolean'
},
{
name: 'high_stock',
label: t`High Stock`,
description: t`Filter by parts which have high stock`,
type: 'boolean'
},
{
name: 'purchaseable',
label: t`Purchaseable`,