diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc7443f5b..262dc7a3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#13166](https://github.com/inventree/InvenTree/pull/13166) Adds an "exclude_from_stock" attribute to the StockLocation model, allowing stock items in specified locations to be excluded from stock availability calculations. + ### Changed ### Removed diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5a6391c8c7..731e8befa4 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 = 570 +INVENTREE_API_VERSION = 571 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v571 -> 2026-11-03 : https://github.com/inventree/InvenTree/pull/13166 + - Adds "exclude_from_stock" field and filter to the StockLocation API endpoint + - Adds "exclude_from_stock" filter to the StockItem API endpoint + v570 -> 2026-11-03 : https://github.com/inventree/InvenTree/pull/13175 - Token API (/api/user/tokens/): "revoked", "revoked_by", "issued_by", "token_version", "revocation_reason" and "last_seen" fields are read-only - Token API (/api/user/tokens/): "expiry" must be between today and 365 days in the future diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 0cb6072aba..1ca0cb72fa 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -310,7 +310,7 @@ class StockLocationFilter(FilterSet): """Meta class options for this filterset.""" model = StockLocation - fields = ['name', 'structural', 'external'] + fields = ['name', 'structural', 'external', 'exclude_from_stock'] location_type = rest_filters.ModelChoiceFilter( queryset=StockLocationType.objects.all(), field_name='location_type' @@ -765,6 +765,16 @@ class StockFilter(FilterSet): return queryset.filter(location__external=True) return queryset.exclude(location__external=True) + exclude_from_stock = rest_filters.BooleanFilter( + label=_('Exclude from Stock'), method='filter_exclude_from_stock' + ) + + def filter_exclude_from_stock(self, queryset, name, value): + """Filter by whether or not the stock item is located in an exclude_from_stock location.""" + if str2bool(value): + return queryset.filter(location__exclude_from_stock=True) + return queryset.exclude(location__exclude_from_stock=True) + in_stock = rest_filters.BooleanFilter(label='In Stock', method='filter_in_stock') def filter_in_stock(self, queryset, name, value): diff --git a/src/backend/InvenTree/stock/migrations/0131_stocklocation_exclude_from_stock.py b/src/backend/InvenTree/stock/migrations/0131_stocklocation_exclude_from_stock.py new file mode 100644 index 0000000000..188546c64e --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0131_stocklocation_exclude_from_stock.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.17 on 2026-11-02 21:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [('stock', '0130_delete_stockitemtracking')] + + operations = [ + migrations.AddField( + model_name='stocklocation', + name='exclude_from_stock', + field=models.BooleanField( + default=False, + help_text='If checked, stock items in this location will not be counted in available stock', + verbose_name='Exclude from Stock', + ), + ) + ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index cc4172ca8e..20c688c9df 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -213,6 +213,14 @@ class StockLocation( help_text=_('This is an external stock location'), ) + exclude_from_stock = models.BooleanField( + default=False, + verbose_name=_('Exclude from Stock'), + help_text=_( + 'If checked, stock items in this location will not be counted in available stock' + ), + ) + location_type = models.ForeignKey( StockLocationType, on_delete=models.SET_NULL, @@ -658,7 +666,7 @@ class StockItem( consumed_by=None, is_building=False, status__in=StockStatusGroups.AVAILABLE_CODES, - ) + ) & (Q(location=None) | Q(location__exclude_from_stock=False)) @classmethod def get_expired_filter(cls): @@ -2318,6 +2326,9 @@ class StockItem( if check_status and self.status not in StockStatusGroups.AVAILABLE_CODES: return False + if check_status and self.location and self.location.exclude_from_stock: + return False + if check_quantity and self.quantity <= 0: return False diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index a55a6db887..8a1fe3475c 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1428,6 +1428,7 @@ class LocationSerializer( 'custom_icon', 'structural', 'external', + 'exclude_from_stock', 'location_type', # Optional fields 'location_type_detail', diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index c38ebce1c8..11711f376c 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -981,6 +981,14 @@ class StockItemListTest(StockAPITestCase): response = self.get_stock(external=False) self.assertEqual(len(response), 28) + def test_filter_exclude_from_stock(self): + """Filter StockItem by exclude_from_stock.""" + response = self.get_stock(exclude_from_stock=True) + self.assertEqual(len(response), 0) + + response = self.get_stock(exclude_from_stock=False) + self.assertEqual(len(response), 29) + def test_filter_available(self): """Filter StockItem by available.""" response = self.get_stock(available=True) diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index 8c731a61a3..7a263ebb66 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -1930,6 +1930,33 @@ class StockLocationTest(InvenTreeTestCase): loc.save() self.assertEqual(loc.icon, '') + def test_exclude_from_stock(self): + """Test that stock items in locations with exclude_from_stock=True are excluded from available stock.""" + part = Part.objects.create( + name='RnD Test Part', description='Part for RnD stock test' + ) + normal_loc = StockLocation.objects.create( + name='Warehouse A', exclude_from_stock=False + ) + rnd_loc = StockLocation.objects.create(name='RnD Lab', exclude_from_stock=True) + + item_normal = StockItem.objects.create( + part=part, location=normal_loc, quantity=10 + ) + item_rnd = StockItem.objects.create(part=part, location=rnd_loc, quantity=25) + + self.assertTrue(item_normal.in_stock) + self.assertFalse(item_rnd.in_stock) + self.assertEqual(part.total_stock, 10) + self.assertEqual(part.available_stock, 10) + + rnd_loc.exclude_from_stock = False + rnd_loc.save() + item_rnd.refresh_from_db() + self.assertTrue(item_rnd.in_stock) + self.assertEqual(part.total_stock, 35) + self.assertEqual(part.available_stock, 35) + class AdminTest(AdminTestCase): """Tests for the admin interface integration.""" diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index cc87aeb4ff..10d1788389 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -2195,6 +2195,7 @@ export function useStockLocationFields(): ApiFormFieldSet { }, structural: {}, external: {}, + exclude_from_stock: {}, custom_icon: { field_type: 'icon' }, diff --git a/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx b/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx index 22fb631548..5f09fa9e45 100644 --- a/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx +++ b/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx @@ -78,6 +78,11 @@ export function StockLocationDetailsPanel({ name: 'external', label: t`External` }, + { + type: 'boolean', + name: 'exclude_from_stock', + label: t`Exclude from Stock` + }, { type: 'string', name: 'location_type_detail.name', diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 6a3a39bcc4..ce91cce8ee 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -320,6 +320,11 @@ function stockItemTableFilters({ label: t`External Location`, description: t`Show items in an external location` }, + { + name: 'exclude_from_stock', + label: t`Exclude from Stock`, + description: t`Show items in a location excluded from stock calculations` + }, TagsFilter({ modelType: ModelType.stockitem }) ]; } diff --git a/src/frontend/src/tables/stock/StockLocationTable.tsx b/src/frontend/src/tables/stock/StockLocationTable.tsx index 35fd16617d..7031f05252 100644 --- a/src/frontend/src/tables/stock/StockLocationTable.tsx +++ b/src/frontend/src/tables/stock/StockLocationTable.tsx @@ -51,6 +51,11 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) { label: t`External`, description: t`Show external locations` }, + { + name: 'exclude_from_stock', + label: t`Exclude from Stock`, + description: t`Show locations excluded from stock calculations` + }, { name: 'has_location_type', label: t`Has location type` @@ -97,6 +102,10 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) { accessor: 'external', defaultVisible: false }), + BooleanColumn({ + accessor: 'exclude_from_stock', + defaultVisible: false + }), { accessor: 'location_type', sortable: false,