diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index a5e2e159fc..e77f81a0f8 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -14,7 +14,7 @@

{% trans "Supplier Parts" %}

{% include "spacer.html" %}
- {% 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" %}', + } }; }