mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-13 21:17:33 +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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -57,6 +57,7 @@ export function usePartFields({
|
||||
},
|
||||
default_expiry: {},
|
||||
minimum_stock: {},
|
||||
maximum_stock: {},
|
||||
responsible: {
|
||||
filters: {
|
||||
is_active: true
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user