From 0716238f3b9dfa47f8ad6cfac6d190e16adc2d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20M=C3=A1rton?= Date: Sat, 19 Nov 2022 12:24:18 +0100 Subject: [PATCH] Implement structural stock locations (#3949) * Implement structural stock locations * Bumped API version --- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/stock/api.py | 2 + .../0090_stocklocation_structural.py | 18 +++++ InvenTree/stock/models.py | 25 +++++++ InvenTree/stock/serializers.py | 1 + InvenTree/stock/test_api.py | 66 +++++++++++++++++++ InvenTree/templates/js/translated/build.js | 17 ++++- InvenTree/templates/js/translated/order.js | 12 +++- InvenTree/templates/js/translated/part.js | 9 ++- InvenTree/templates/js/translated/stock.js | 19 +++++- 10 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 InvenTree/stock/migrations/0090_stocklocation_structural.py 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" %}: @@ -2401,6 +2411,9 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) { var fields = { location: { value: options.location, + filters: { + structural: false, + } }, exclude_location: {}, interchangeable: { diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 4e0ca3e06b..a408e4643a 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -888,7 +888,11 @@ function poLineItemFields(options={}) { purchase_price: {}, purchase_price_currency: {}, target_date: {}, - destination: {}, + destination: { + filters: { + structural: false, + } + }, notes: {}, }; @@ -1688,7 +1692,11 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { constructForm(`/api/order/po/${order_id}/receive/`, { method: 'POST', fields: { - location: {}, + location: { + filters: { + structural: false, + } + }, }, preFormContent: html, confirm: true, diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 1896cd7ee7..fed831e571 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -99,6 +99,9 @@ function partFields(options={}) { icon: 'fa-link', }, default_location: { + filters: { + structural: false, + } }, default_supplier: { filters: { @@ -297,7 +300,11 @@ function categoryFields() { }, name: {}, description: {}, - default_location: {}, + default_location: { + filters: { + structural: false, + } + }, default_keywords: { icon: 'fa-key', }, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 1a6cddadcc..76d6f3ec5f 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -80,6 +80,9 @@ function serializeStockItem(pk, options={}) { }, destination: { icon: 'fa-sitemap', + filters: { + structural: false, + } }, notes: {}, }; @@ -114,6 +117,7 @@ function stockLocationFields(options={}) { name: {}, description: {}, owner: {}, + structural: {}, icon: { help_text: `{% trans "Icon (optional) - Explore all available icons on" %} Font Awesome.`, placeholder: 'fas fa-box', @@ -280,6 +284,9 @@ function stockItemFields(options={}) { }, location: { icon: 'fa-sitemap', + filters: { + structural: false, + }, }, quantity: { help_text: '{% trans "Enter initial quantity for this stock item" %}', @@ -838,6 +845,9 @@ function mergeStockItems(items, options={}) { location: { value: location, icon: 'fa-sitemap', + filters: { + structural: false, + } }, notes: {}, allow_mismatched_suppliers: {}, @@ -1106,7 +1116,11 @@ function adjustStock(action, items, options={}) { var extraFields = {}; if (specifyLocation) { - extraFields.location = {}; + extraFields.location = { + filters: { + structural: false, + }, + }; } if (action != 'delete') { @@ -2810,6 +2824,9 @@ function uninstallStockItem(installed_item_id, options={}) { fields: { location: { icon: 'fa-sitemap', + filters: { + structural: false, + } }, note: {}, },