mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Implement structural stock locations (#3949)
* Implement structural stock locations * Bumped API version
This commit is contained in:
parent
bc8a6ae4b8
commit
0716238f3b
@ -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
|
||||||
|
|
||||||
|
@ -309,6 +309,8 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI):
|
|||||||
]
|
]
|
||||||
|
|
||||||
filterset_fields = [
|
filterset_fields = [
|
||||||
|
'name',
|
||||||
|
'structural'
|
||||||
]
|
]
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
|
18
InvenTree/stock/migrations/0090_stocklocation_structural.py
Normal file
18
InvenTree/stock/migrations/0090_stocklocation_structural.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
||||||
|
@ -606,6 +606,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
'items',
|
'items',
|
||||||
'owner',
|
'owner',
|
||||||
'icon',
|
'icon',
|
||||||
|
'structural',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
|
@ -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."""
|
||||||
|
@ -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: {
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
},
|
},
|
||||||
|
@ -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: {},
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user