diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index bf9711ebe3..8887982784 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -2,11 +2,14 @@
# InvenTree API version
-INVENTREE_API_VERSION = 82
+INVENTREE_API_VERSION = 83
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
+v83 -> 2022-11-19 : https://github.com/inventree/InvenTree/pull/3949
+ - Add support for structural Stock locations
+
v82 -> 2022-11-16 : https://github.com/inventree/InvenTree/pull/3931
- Add support for structural Part categories
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index e5de1f54f5..9778ceaebe 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -309,6 +309,8 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI):
]
filterset_fields = [
+ 'name',
+ 'structural'
]
search_fields = [
diff --git a/InvenTree/stock/migrations/0090_stocklocation_structural.py b/InvenTree/stock/migrations/0090_stocklocation_structural.py
new file mode 100644
index 0000000000..e37312fef2
--- /dev/null
+++ b/InvenTree/stock/migrations/0090_stocklocation_structural.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.16 on 2022-11-18 15:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('stock', '0089_alter_stockitem_purchase_price'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='stocklocation',
+ name='structural',
+ field=models.BooleanField(default=False, help_text="Stock items may not be directly located into a structural stock locations, but may be located to it's child locations.", verbose_name='Structural'),
+ ),
+ ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index b83a070fff..445a5cd90f 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -107,6 +107,14 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
help_text=_('Select Owner'),
related_name='stock_locations')
+ structural = models.BooleanField(
+ default=False,
+ verbose_name=_('Structural'),
+ help_text=_(
+ 'Stock items may not be directly located into a structural stock locations, '
+ 'but may be located to it\'s child locations.'),
+ )
+
def get_location_owner(self):
"""Get the closest "owner" for this location.
@@ -139,6 +147,17 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
return user in owner.get_related_owners(include_group=True)
+ def clean(self):
+ """Custom clean action for the StockLocation model:
+
+ - Ensure stock location can't be made structural if stock items already located to them
+ """
+ if self.pk and self.structural and self.item_count > 0:
+ raise ValidationError(
+ _("You cannot make this stock location structural because some stock items "
+ "are already located into it!"))
+ super().clean()
+
def get_absolute_url(self):
"""Return url for instance."""
return reverse('stock-location-detail', kwargs={'pk': self.id})
@@ -496,8 +515,14 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
- The 'part' and 'supplier_part.part' fields cannot point to the same Part object
- The 'part' is not virtual
- The 'part' does not belong to itself
+ - The location is not structural
- Quantity must be 1 if the StockItem has a serial number
"""
+
+ if self.location is not None and self.location.structural:
+ raise ValidationError(
+ {'location': _("Stock items cannot be located into structural stock locations!")})
+
super().clean()
# Strip serial number field
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index d319d387cc..9708ed15d6 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -606,6 +606,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'items',
'owner',
'icon',
+ 'structural',
]
read_only_fields = [
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index 06cdbd04aa..412944204c 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta
from enum import IntEnum
import django.http
+from django.core.exceptions import ValidationError
from django.urls import reverse
import tablib
@@ -225,6 +226,71 @@ class StockLocationTest(StockAPITestCase):
child.refresh_from_db()
self.assertEqual(child.parent, parent_stock_location)
+ def test_stock_location_structural(self):
+ """Test the effectiveness of structural stock locations
+
+ Make sure:
+ - Stock items cannot be created in structural locations
+ - Stock items cannot be located to structural locations
+ - Check that stock location change to structural fails if items located into it
+ """
+
+ # Create our structural stock location
+ structural_location = StockLocation.objects.create(
+ name='Structural stock location',
+ description='This is the structural stock location',
+ parent=None,
+ structural=True
+ )
+
+ stock_item_count_before = StockItem.objects.count()
+
+ # Make sure that we get an error if we try to create a stock item in the structural location
+ with self.assertRaises(ValidationError):
+ item = StockItem.objects.create(
+ batch="Stock item which shall not be created",
+ location=structural_location
+ )
+
+ # Ensure that the stock item really did not get created in the structural location
+ self.assertEqual(stock_item_count_before, StockItem.objects.count())
+
+ # Create a non-structural location for test stock location change
+ non_structural_location = StockLocation.objects.create(
+ name='Non-structural category',
+ description='This is a non-structural category',
+ parent=None,
+ structural=False
+ )
+
+ # Construct a part for stock item creation
+ part = Part.objects.create(
+ name='Part for stock item creation', description='Part for stock item creation',
+ category=None,
+ is_template=False,
+ )
+
+ # Create the test stock item located to a non-structural category
+ item = StockItem.objects.create(
+ batch="Item which will be tried to relocated to a structural location",
+ location=non_structural_location,
+ part=part
+ )
+
+ # Try to relocate it to a structural location
+ item.location = structural_location
+ with self.assertRaises(ValidationError):
+ item.save()
+
+ # Ensure that the item did not get saved to the DB
+ item.refresh_from_db()
+ self.assertEqual(item.location.pk, non_structural_location.pk)
+
+ # Try to change the non-structural location to structural while items located into it
+ non_structural_location.structural = True
+ with self.assertRaises(ValidationError):
+ non_structural_location.full_clean()
+
class StockItemListTest(StockAPITestCase):
"""Tests for the StockItem API LIST endpoint."""
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index f23b24e817..316ae03c2f 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -60,9 +60,15 @@ function buildFormFields() {
},
take_from: {
icon: 'fa-sitemap',
+ filters: {
+ structural: false,
+ }
},
destination: {
icon: 'fa-sitemap',
+ filters: {
+ structural: false,
+ }
},
link: {
icon: 'fa-link',
@@ -524,7 +530,11 @@ function completeBuildOutputs(build_id, outputs, options={}) {
preFormContent: html,
fields: {
status: {},
- location: {},
+ location: {
+ filters: {
+ structural: false,
+ },
+ },
notes: {},
accept_incomplete_allocation: {},
},
@@ -2391,7 +2401,7 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
{% trans "Automatic Stock Allocation" %}
{% trans "Stock items will be automatically allocated to this build order, according to the provided guidelines" %}: