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:
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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.
|
- [#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.
|
- [#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
|
- [#11861](https://github.com/inventree/InvenTree/pull/11861) adds support for bulk-replacing a component in multiple BOMs simultaneously
|
||||||
|
|||||||
+14
-1
@@ -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.
|
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 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.
|
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
|
## 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
|
### 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.
|
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
|
## 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:
|
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 information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""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 = """
|
||||||
|
|
||||||
|
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
|
v485 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11631
|
||||||
- Adds "raw_amount" field to the BomItem API endpoint
|
- 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'
|
# Filter items which have an 'in_stock' level higher than 'minimum_stock'
|
||||||
return queryset.filter(Q(total_in_stock__gte=F('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 filter
|
||||||
has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
|
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_location: Where the item is normally stored (may be null)
|
||||||
default_expiry: The default expiry duration for any StockItem instances of this part
|
default_expiry: The default expiry duration for any StockItem instances of this part
|
||||||
minimum_stock: Minimum preferred quantity to keep in stock
|
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')
|
units: Units of measure for this part (default='pcs')
|
||||||
salable: Can this part be sold to customers?
|
salable: Can this part be sold to customers?
|
||||||
assembly: Can this part be build from other parts?
|
assembly: Can this part be build from other parts?
|
||||||
@@ -1232,6 +1233,15 @@ class Part(
|
|||||||
help_text=_('Minimum allowed stock level'),
|
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(
|
units = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
default='',
|
default='',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ class PartSerializer(
|
|||||||
'link',
|
'link',
|
||||||
'locked',
|
'locked',
|
||||||
'minimum_stock',
|
'minimum_stock',
|
||||||
|
'maximum_stock',
|
||||||
'name',
|
'name',
|
||||||
'notes',
|
'notes',
|
||||||
'parameters',
|
'parameters',
|
||||||
@@ -901,6 +902,10 @@ class PartSerializer(
|
|||||||
required=False, label=_('Minimum Stock'), default=0
|
required=False, label=_('Minimum Stock'), default=0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
maximum_stock = serializers.FloatField(
|
||||||
|
required=False, label=_('Maximum Stock'), default=0
|
||||||
|
)
|
||||||
|
|
||||||
image = InvenTree.serializers.InvenTreeImageSerializerField(
|
image = InvenTree.serializers.InvenTreeImageSerializerField(
|
||||||
required=False, allow_null=True
|
required=False, allow_null=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2649,6 +2649,58 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(data['building'], 55)
|
self.assertEqual(data['building'], 55)
|
||||||
self.assertEqual(data['scheduled_to_build'], 32)
|
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):
|
class BomItemTest(InvenTreeAPITestCase):
|
||||||
"""Unit tests for the BomItem API."""
|
"""Unit tests for the BomItem API."""
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
|
|||||||
virtual: false
|
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({
|
QueryCountDashboardWidget({
|
||||||
title: t`Required for Build Orders`,
|
title: t`Required for Build Orders`,
|
||||||
label: 'bld-req',
|
label: 'bld-req',
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export function RenderPart(
|
|||||||
} else if (stock != null) {
|
} else if (stock != null) {
|
||||||
badgeText = `${t`Stock`}: ${formatDecimal(stock)}`;
|
badgeText = `${t`Stock`}: ${formatDecimal(stock)}`;
|
||||||
badgeColor = instance.minimum_stock > stock ? 'yellow' : 'green';
|
badgeColor = instance.minimum_stock > stock ? 'yellow' : 'green';
|
||||||
|
|
||||||
|
if (instance.maximum_stock > 0 && stock > instance.maximum_stock) {
|
||||||
|
badgeColor = 'teal';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extra: ReactNode[] = [];
|
const extra: ReactNode[] = [];
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function usePartFields({
|
|||||||
},
|
},
|
||||||
default_expiry: {},
|
default_expiry: {},
|
||||||
minimum_stock: {},
|
minimum_stock: {},
|
||||||
|
maximum_stock: {},
|
||||||
responsible: {
|
responsible: {
|
||||||
filters: {
|
filters: {
|
||||||
is_active: true
|
is_active: true
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ const icons: InvenTreeIconType = {
|
|||||||
unallocated_stock: IconPackage,
|
unallocated_stock: IconPackage,
|
||||||
total_in_stock: IconPackages,
|
total_in_stock: IconPackages,
|
||||||
minimum_stock: IconFlag,
|
minimum_stock: IconFlag,
|
||||||
|
maximum_stock: IconFlag,
|
||||||
allocated_to_build_orders: IconTool,
|
allocated_to_build_orders: IconTool,
|
||||||
allocated_to_sales_orders: IconTruck,
|
allocated_to_sales_orders: IconTruck,
|
||||||
can_build: IconTools,
|
can_build: IconTools,
|
||||||
|
|||||||
@@ -454,6 +454,13 @@ export default function PartDetail() {
|
|||||||
unit: part.units,
|
unit: part.units,
|
||||||
label: t`Minimum Stock`,
|
label: t`Minimum Stock`,
|
||||||
hidden: part.minimum_stock <= 0
|
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);
|
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 [
|
return [
|
||||||
<DetailsBadge
|
<DetailsBadge
|
||||||
label={`${t`In Stock`}: ${formatDecimal(partRequirements.total_stock)}`}
|
label={`${t`In Stock`}: ${formatDecimal(partRequirements.total_stock)}`}
|
||||||
color={
|
color={stockColor}
|
||||||
partRequirements.total_stock >= part.minimum_stock
|
|
||||||
? 'green'
|
|
||||||
: 'orange'
|
|
||||||
}
|
|
||||||
visible={!part.virtual && partRequirements.total_stock > 0}
|
visible={!part.virtual && partRequirements.total_stock > 0}
|
||||||
key='in_stock'
|
key='in_stock'
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ function partTableColumns(): TableColumn[] {
|
|||||||
(record?.allocated_to_sales_orders ?? 0);
|
(record?.allocated_to_sales_orders ?? 0);
|
||||||
const available = Math.max(0, stock - allocated);
|
const available = Math.max(0, stock - allocated);
|
||||||
const min_stock = record?.minimum_stock ?? 0;
|
const min_stock = record?.minimum_stock ?? 0;
|
||||||
|
const max_stock = record?.maximum_stock ?? 0;
|
||||||
|
|
||||||
let text = String(formatDecimal(stock));
|
let text = String(formatDecimal(stock));
|
||||||
|
|
||||||
@@ -112,6 +113,14 @@ function partTableColumns(): TableColumn[] {
|
|||||||
color = 'orange';
|
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) {
|
if (record.ordering > 0) {
|
||||||
extra.push(
|
extra.push(
|
||||||
<Text key='on-order'>{`${t`On Order`}: ${formatDecimal(record.ordering)}`}</Text>
|
<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`,
|
description: t`Filter by parts which have low stock`,
|
||||||
type: 'boolean'
|
type: 'boolean'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'high_stock',
|
||||||
|
label: t`High Stock`,
|
||||||
|
description: t`Filter by parts which have high stock`,
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'purchaseable',
|
name: 'purchaseable',
|
||||||
label: t`Purchaseable`,
|
label: t`Purchaseable`,
|
||||||
|
|||||||
Reference in New Issue
Block a user