- {% if roles.purchase_order.add %}
+ {% if roles.purchase_order.add and not part.virtual %}
@@ -61,7 +61,7 @@
{% trans "Manufacturer Parts" %}
{% include "spacer.html" %}
- {% if roles.purchase_order.add %}
+ {% if roles.purchase_order.add and not part.virtual %}
diff --git a/InvenTree/order/migrations/0071_auto_20220628_0133.py b/InvenTree/order/migrations/0071_auto_20220628_0133.py
new file mode 100644
index 0000000000..929c71d008
--- /dev/null
+++ b/InvenTree/order/migrations/0071_auto_20220628_0133.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.2.13 on 2022-06-28 01:33
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('stock', '0077_alter_stockitem_notes'),
+ ('part', '0079_alter_part_notes'),
+ ('order', '0070_auto_20220620_0728'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='salesorderallocation',
+ name='item',
+ field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'part__salable': True, 'part__virtual': False, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.stockitem', verbose_name='Item'),
+ ),
+ migrations.AlterField(
+ model_name='salesorderlineitem',
+ name='part',
+ field=models.ForeignKey(help_text='Part', limit_choices_to={'salable': True, 'virtual': False}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_order_line_items', to='part.part', verbose_name='Part'),
+ ),
+ ]
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index 3f4274fb95..ff5ef4e60b 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -1103,6 +1103,22 @@ class SalesOrderLineItem(OrderLineItem):
"""Return the API URL associated with the SalesOrderLineItem model"""
return reverse('api-so-line-list')
+ def clean(self):
+ """Perform extra validation steps for this SalesOrderLineItem instance"""
+
+ super().clean()
+
+ if self.part:
+ if self.part.virtual:
+ raise ValidationError({
+ 'part': _("Virtual part cannot be assigned to a sales order")
+ })
+
+ if not self.part.salable:
+ raise ValidationError({
+ 'part': _("Only salable parts can be assigned to a sales order")
+ })
+
order = models.ForeignKey(
SalesOrder,
on_delete=models.CASCADE,
@@ -1111,7 +1127,16 @@ class SalesOrderLineItem(OrderLineItem):
help_text=_('Sales Order')
)
- part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True})
+ part = models.ForeignKey(
+ 'part.Part', on_delete=models.SET_NULL,
+ related_name='sales_order_line_items',
+ null=True,
+ verbose_name=_('Part'),
+ help_text=_('Part'),
+ limit_choices_to={
+ 'salable': True,
+ 'virtual': False,
+ })
sale_price = InvenTreeModelMoneyField(
max_digits=19,
@@ -1409,6 +1434,7 @@ class SalesOrderAllocation(models.Model):
related_name='sales_order_allocations',
limit_choices_to={
'part__salable': True,
+ 'part__virtual': False,
'belongs_to': None,
'sales_order': None,
},
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 4119b6fb92..4ac5d28d08 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -20,7 +20,7 @@
{% trans "Part Stock" %}
{% include "spacer.html" %}
- {% if roles.stock.add %}
+ {% if roles.stock.add and not part.virtual %}
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 2f4e73521d..97bd10b1fd 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -342,6 +342,7 @@ class StockItem(MetadataMixin, MPTTModel):
- Unique serial number requirement
- Adds a transaction note when the item is first created.
"""
+
self.validate_unique()
self.clean()
@@ -439,6 +440,7 @@ class StockItem(MetadataMixin, MPTTModel):
The following validation checks are performed:
- 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
- Quantity must be 1 if the StockItem has a serial number
"""
@@ -453,12 +455,18 @@ class StockItem(MetadataMixin, MPTTModel):
self.batch = self.batch.strip()
try:
+ # Trackable parts must have integer values for quantity field!
if self.part.trackable:
- # Trackable parts must have integer values for quantity field!
if self.quantity != int(self.quantity):
raise ValidationError({
'quantity': _('Quantity must be integer value for trackable parts')
})
+
+ # Virtual parts cannot have stock items created against them
+ if self.part.virtual:
+ raise ValidationError({
+ 'part': _("Stock item cannot be created for virtual parts"),
+ })
except PartModels.Part.DoesNotExist:
# For some reason the 'clean' process sometimes throws errors because self.part does not exist
# It *seems* that this only occurs in unit testing, though.
@@ -582,7 +590,8 @@ class StockItem(MetadataMixin, MPTTModel):
part = models.ForeignKey(
'part.Part', on_delete=models.CASCADE,
verbose_name=_('Base Part'),
- related_name='stock_items', help_text=_('Base part'),
+ related_name='stock_items',
+ help_text=_('Base part'),
limit_choices_to={
'virtual': False
})
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index 8801b3a7c9..ecd58f24aa 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -79,6 +79,21 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
- Includes serialization for the item location
"""
+ part = serializers.PrimaryKeyRelatedField(
+ queryset=part_models.Part.objects.all(),
+ many=False, allow_null=False,
+ help_text=_("Base Part"),
+ label=_("Part"),
+ )
+
+ def validate_part(self, part):
+ """Ensure the provided Part instance is valid"""
+
+ if part.virtual:
+ raise ValidationError(_("Stock item cannot be created for virtual parts"))
+
+ return part
+
def update(self, instance, validated_data):
"""Custom update method to pass the user information through to the instance."""
instance._user = self.context['user']
diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js
index c806afd4b1..d2a1f4c9bd 100644
--- a/InvenTree/templates/js/translated/table_filters.js
+++ b/InvenTree/templates/js/translated/table_filters.js
@@ -429,19 +429,27 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Include subcategories" %}',
description: '{% trans "Include parts in subcategories" %}',
},
- has_ipn: {
- type: 'bool',
- title: '{% trans "Has IPN" %}',
- description: '{% trans "Part has internal part number" %}',
- },
active: {
type: 'bool',
title: '{% trans "Active" %}',
description: '{% trans "Show active parts" %}',
},
- is_template: {
+ assembly: {
type: 'bool',
- title: '{% trans "Template" %}',
+ title: '{% trans "Assembly" %}',
+ },
+ unallocated_stock: {
+ type: 'bool',
+ title: '{% trans "Available stock" %}',
+ },
+ component: {
+ type: 'bool',
+ title: '{% trans "Component" %}',
+ },
+ has_ipn: {
+ type: 'bool',
+ title: '{% trans "Has IPN" %}',
+ description: '{% trans "Part has internal part number" %}',
},
has_stock: {
type: 'bool',
@@ -451,34 +459,30 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Low stock" %}',
},
- unallocated_stock: {
+ purchaseable: {
type: 'bool',
- title: '{% trans "Available stock" %}',
- },
- assembly: {
- type: 'bool',
- title: '{% trans "Assembly" %}',
- },
- component: {
- type: 'bool',
- title: '{% trans "Component" %}',
- },
- starred: {
- type: 'bool',
- title: '{% trans "Subscribed" %}',
+ title: '{% trans "Purchasable" %}',
},
salable: {
type: 'bool',
title: '{% trans "Salable" %}',
},
+ starred: {
+ type: 'bool',
+ title: '{% trans "Subscribed" %}',
+ },
+ is_template: {
+ type: 'bool',
+ title: '{% trans "Template" %}',
+ },
trackable: {
type: 'bool',
title: '{% trans "Trackable" %}',
},
- purchaseable: {
+ virtual: {
type: 'bool',
- title: '{% trans "Purchasable" %}',
- },
+ title: '{% trans "Virtual" %}',
+ }
};
}