Implement structural stock locations (#3949)

* Implement structural stock locations

* Bumped API version
This commit is contained in:
Miklós Márton 2022-11-19 12:24:18 +01:00 committed by GitHub
parent bc8a6ae4b8
commit 0716238f3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 167 additions and 7 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # 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 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 v82 -> 2022-11-16 : https://github.com/inventree/InvenTree/pull/3931
- Add support for structural Part categories - Add support for structural Part categories

View File

@ -309,6 +309,8 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI):
] ]
filterset_fields = [ filterset_fields = [
'name',
'structural'
] ]
search_fields = [ search_fields = [

View File

@ -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'),
),
]

View File

@ -107,6 +107,14 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
help_text=_('Select Owner'), help_text=_('Select Owner'),
related_name='stock_locations') 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): def get_location_owner(self):
"""Get the closest "owner" for this location. """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) 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): def get_absolute_url(self):
"""Return url for instance.""" """Return url for instance."""
return reverse('stock-location-detail', kwargs={'pk': self.id}) 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' and 'supplier_part.part' fields cannot point to the same Part object
- The 'part' is not virtual - The 'part' is not virtual
- The 'part' does not belong to itself - The 'part' does not belong to itself
- The location is not structural
- Quantity must be 1 if the StockItem has a serial number - 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() super().clean()
# Strip serial number field # Strip serial number field

View File

@ -606,6 +606,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'items', 'items',
'owner', 'owner',
'icon', 'icon',
'structural',
] ]
read_only_fields = [ read_only_fields = [

View File

@ -6,6 +6,7 @@ from datetime import datetime, timedelta
from enum import IntEnum from enum import IntEnum
import django.http import django.http
from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
import tablib import tablib
@ -225,6 +226,71 @@ class StockLocationTest(StockAPITestCase):
child.refresh_from_db() child.refresh_from_db()
self.assertEqual(child.parent, parent_stock_location) 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): class StockItemListTest(StockAPITestCase):
"""Tests for the StockItem API LIST endpoint.""" """Tests for the StockItem API LIST endpoint."""

View File

@ -60,9 +60,15 @@ function buildFormFields() {
}, },
take_from: { take_from: {
icon: 'fa-sitemap', icon: 'fa-sitemap',
filters: {
structural: false,
}
}, },
destination: { destination: {
icon: 'fa-sitemap', icon: 'fa-sitemap',
filters: {
structural: false,
}
}, },
link: { link: {
icon: 'fa-link', icon: 'fa-link',
@ -524,7 +530,11 @@ function completeBuildOutputs(build_id, outputs, options={}) {
preFormContent: html, preFormContent: html,
fields: { fields: {
status: {}, status: {},
location: {}, location: {
filters: {
structural: false,
},
},
notes: {}, notes: {},
accept_incomplete_allocation: {}, accept_incomplete_allocation: {},
}, },
@ -2391,7 +2401,7 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
<strong>{% trans "Automatic Stock Allocation" %}</strong><br> <strong>{% trans "Automatic Stock Allocation" %}</strong><br>
{% trans "Stock items will be automatically allocated to this build order, according to the provided guidelines" %}: {% trans "Stock items will be automatically allocated to this build order, according to the provided guidelines" %}:
<ul> <ul>
<li>{% trans "If a location is specifed, stock will only be allocated from that location" %}</li> <li>{% trans "If a location is specified, stock will only be allocated from that location" %}</li>
<li>{% trans "If stock is considered interchangeable, it will be allocated from the first location it is found" %}</li> <li>{% trans "If stock is considered interchangeable, it will be allocated from the first location it is found" %}</li>
<li>{% trans "If substitute stock is allowed, it will be used where stock of the primary part cannot be found" %}</li> <li>{% trans "If substitute stock is allowed, it will be used where stock of the primary part cannot be found" %}</li>
</ul> </ul>
@ -2401,6 +2411,9 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
var fields = { var fields = {
location: { location: {
value: options.location, value: options.location,
filters: {
structural: false,
}
}, },
exclude_location: {}, exclude_location: {},
interchangeable: { interchangeable: {

View File

@ -888,7 +888,11 @@ function poLineItemFields(options={}) {
purchase_price: {}, purchase_price: {},
purchase_price_currency: {}, purchase_price_currency: {},
target_date: {}, target_date: {},
destination: {}, destination: {
filters: {
structural: false,
}
},
notes: {}, notes: {},
}; };
@ -1688,7 +1692,11 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
constructForm(`/api/order/po/${order_id}/receive/`, { constructForm(`/api/order/po/${order_id}/receive/`, {
method: 'POST', method: 'POST',
fields: { fields: {
location: {}, location: {
filters: {
structural: false,
}
},
}, },
preFormContent: html, preFormContent: html,
confirm: true, confirm: true,

View File

@ -99,6 +99,9 @@ function partFields(options={}) {
icon: 'fa-link', icon: 'fa-link',
}, },
default_location: { default_location: {
filters: {
structural: false,
}
}, },
default_supplier: { default_supplier: {
filters: { filters: {
@ -297,7 +300,11 @@ function categoryFields() {
}, },
name: {}, name: {},
description: {}, description: {},
default_location: {}, default_location: {
filters: {
structural: false,
}
},
default_keywords: { default_keywords: {
icon: 'fa-key', icon: 'fa-key',
}, },

View File

@ -80,6 +80,9 @@ function serializeStockItem(pk, options={}) {
}, },
destination: { destination: {
icon: 'fa-sitemap', icon: 'fa-sitemap',
filters: {
structural: false,
}
}, },
notes: {}, notes: {},
}; };
@ -114,6 +117,7 @@ function stockLocationFields(options={}) {
name: {}, name: {},
description: {}, description: {},
owner: {}, owner: {},
structural: {},
icon: { icon: {
help_text: `{% trans "Icon (optional) - Explore all available icons on" %} <a href="https://fontawesome.com/v5/search?s=solid" target="_blank" rel="noopener noreferrer">Font Awesome</a>.`, help_text: `{% trans "Icon (optional) - Explore all available icons on" %} <a href="https://fontawesome.com/v5/search?s=solid" target="_blank" rel="noopener noreferrer">Font Awesome</a>.`,
placeholder: 'fas fa-box', placeholder: 'fas fa-box',
@ -280,6 +284,9 @@ function stockItemFields(options={}) {
}, },
location: { location: {
icon: 'fa-sitemap', icon: 'fa-sitemap',
filters: {
structural: false,
},
}, },
quantity: { quantity: {
help_text: '{% trans "Enter initial quantity for this stock item" %}', help_text: '{% trans "Enter initial quantity for this stock item" %}',
@ -838,6 +845,9 @@ function mergeStockItems(items, options={}) {
location: { location: {
value: location, value: location,
icon: 'fa-sitemap', icon: 'fa-sitemap',
filters: {
structural: false,
}
}, },
notes: {}, notes: {},
allow_mismatched_suppliers: {}, allow_mismatched_suppliers: {},
@ -1106,7 +1116,11 @@ function adjustStock(action, items, options={}) {
var extraFields = {}; var extraFields = {};
if (specifyLocation) { if (specifyLocation) {
extraFields.location = {}; extraFields.location = {
filters: {
structural: false,
},
};
} }
if (action != 'delete') { if (action != 'delete') {
@ -2810,6 +2824,9 @@ function uninstallStockItem(installed_item_id, options={}) {
fields: { fields: {
location: { location: {
icon: 'fa-sitemap', icon: 'fa-sitemap',
filters: {
structural: false,
}
}, },
note: {}, note: {},
}, },