diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f929dbb00..e12568aa52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/docs/part/index.md b/docs/docs/part/index.md index 076da58b25..dc250131bf 100644 --- a/docs/docs/part/index.md +++ b/docs/docs/part/index.md @@ -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: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 1c5a8142a3..f9e89d5712 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 60daddad09..3df7e3a0ef 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -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') diff --git a/src/backend/InvenTree/part/migrations/0150_part_maximum_stock.py b/src/backend/InvenTree/part/migrations/0150_part_maximum_stock.py new file mode 100644 index 0000000000..439dc57b69 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0150_part_maximum_stock.py @@ -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", + ), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index e66393b777..3ad08f999d 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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='', diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 882f03e26e..3826f7407f 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -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 ) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 9eb9c98f5d..aea80d5e7f 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -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.""" diff --git a/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx b/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx index 979d4d0f6c..5ef7b5f04f 100644 --- a/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx +++ b/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx @@ -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', diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx index ed5ddf7d9c..9edb9bf68c 100644 --- a/src/frontend/src/components/render/Part.tsx +++ b/src/frontend/src/components/render/Part.tsx @@ -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[] = []; diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 0b981ace19..32f6c85391 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -57,6 +57,7 @@ export function usePartFields({ }, default_expiry: {}, minimum_stock: {}, + maximum_stock: {}, responsible: { filters: { is_active: true diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index b50e04e4cb..00f9726f80 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -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, diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 4c56646f50..374f131778 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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 [ = part.minimum_stock - ? 'green' - : 'orange' - } + color={stockColor} visible={!part.virtual && partRequirements.total_stock > 0} key='in_stock' />, diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx index fc4bae851c..52ac388404 100644 --- a/src/frontend/src/tables/part/PartTable.tsx +++ b/src/frontend/src/tables/part/PartTable.tsx @@ -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( + + {`${t`Maximum stock`}: ${formatDecimal(max_stock)}`} + + ); + } + if (record.ordering > 0) { extra.push( {`${t`On Order`}: ${formatDecimal(record.ordering)}`} @@ -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`,