From 1373425c292a4905fd81538b7a9b4f53e24b2ebb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 22 Apr 2020 13:11:19 +1000 Subject: [PATCH] Update definition for StockItemAllocation model - Limit foreignkey choices - Error checking - Check if a StockItem is over-allocated - Fix API serialization and filtering --- InvenTree/build/models.py | 9 ++++ InvenTree/order/admin.py | 13 ++++- .../migrations/0025_auto_20200422_0222.py | 18 +++++++ .../migrations/0026_auto_20200422_0224.py | 20 ++++++++ .../migrations/0027_auto_20200422_0236.py | 20 ++++++++ InvenTree/order/models.py | 49 ++++++++++++++++++- InvenTree/stock/api.py | 22 +++------ InvenTree/stock/models.py | 38 +++++++++++++- InvenTree/stock/serializers.py | 2 + .../stock/templates/stock/item_base.html | 13 +++-- 10 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 InvenTree/order/migrations/0025_auto_20200422_0222.py create mode 100644 InvenTree/order/migrations/0026_auto_20200422_0224.py create mode 100644 InvenTree/order/migrations/0027_auto_20200422_0236.py diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 8730fd6700..532526ea60 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -393,6 +393,15 @@ class BuildItem(models.Model): q=self.stock_item.quantity ))] + if self.stock_item.quantity - self.stock_item.allocation_count() < self.quantity: + errors['quantity'] = _('StockItem is over-allocated') + + if self.quantity <= 0: + errors['quantity'] = _('Allocation quantity must be greater than zero') + + if self.stock_item.serial and not self.quantity == 1: + errors['quantity'] = _('Quantity must be 1 for serialized stock') + except StockItem.DoesNotExist: pass diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 459b7a3821..a213f09764 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -10,7 +10,7 @@ from import_export.fields import Field from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem - +from .models import SalesOrderAllocation class PurchaseOrderAdmin(ImportExportModelAdmin): @@ -86,8 +86,19 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin): ) +class SalesOrderAllocationAdmin(ImportExportModelAdmin): + + list_display = ( + 'line', + 'item', + 'quantity' + ) + + admin.site.register(PurchaseOrder, PurchaseOrderAdmin) admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) admin.site.register(SalesOrder, SalesOrderAdmin) admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) + +admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) diff --git a/InvenTree/order/migrations/0025_auto_20200422_0222.py b/InvenTree/order/migrations/0025_auto_20200422_0222.py new file mode 100644 index 0000000000..34d0114ac9 --- /dev/null +++ b/InvenTree/order/migrations/0025_auto_20200422_0222.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-04-22 02:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0031_auto_20200422_0209'), + ('order', '0024_salesorderallocation'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='salesorderallocation', + unique_together={('line', 'item')}, + ), + ] diff --git a/InvenTree/order/migrations/0026_auto_20200422_0224.py b/InvenTree/order/migrations/0026_auto_20200422_0224.py new file mode 100644 index 0000000000..c92280898e --- /dev/null +++ b/InvenTree/order/migrations/0026_auto_20200422_0224.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-22 02:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0031_auto_20200422_0209'), + ('order', '0025_auto_20200422_0222'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorderallocation', + name='item', + field=models.OneToOneField(limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocation', to='stock.StockItem'), + ), + ] diff --git a/InvenTree/order/migrations/0027_auto_20200422_0236.py b/InvenTree/order/migrations/0027_auto_20200422_0236.py new file mode 100644 index 0000000000..a4af5aedd3 --- /dev/null +++ b/InvenTree/order/migrations/0027_auto_20200422_0236.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-22 02:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0031_auto_20200422_0209'), + ('order', '0026_auto_20200422_0224'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorderallocation', + name='item', + field=models.ForeignKey(limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index bfaff7e84e..8e9910cfc6 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -398,8 +398,55 @@ class SalesOrderAllocation(models.Model): """ + class Meta: + unique_together = [ + # Cannot allocate any given StockItem to the same line more than once + ('line', 'item'), + ] + + def clean(self): + """ + Validate the SalesOrderAllocation object: + + - Cannot allocate stock to a line item without a part reference + - The referenced part must match the part associated with the line item + - Allocated quantity cannot exceed the quantity of the stock item + - Allocation quantity must be "1" if the StockItem is serialized + - Allocation quantity cannot be zero + """ + + super().clean() + + errors = {} + + try: + if not self.line.part == self.item.part: + errors['item'] = _('Cannot allocate stock item to a line with a different part') + except Part.DoesNotExist: + errors['line'] = _('Cannot allocate stock to a line without a part') + + if self.quantity > self.item.quantity: + errors['quantity'] = _('Allocation quantity cannot exceed stock quantity') + + if self.item.quantity - self.item.allocation_count() < self.quantity: + errors['quantity'] = _('StockItem is over-allocated') + + if self.quantity <= 0: + errors['quantity'] = _('Allocation quantity must be greater than zero') + + if self.item.serial and not self.quantity == 1: + errors['quantity'] = _('Quantity must be 1 for serialized stock item') + + if len(errors) > 0: + raise ValidationError(errors) + line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, related_name='allocations') - item = models.OneToOneField('stock.StockItem', on_delete=models.CASCADE, related_name='sales_order_allocation') + item = models.ForeignKey( + 'stock.StockItem', + on_delete=models.CASCADE, + related_name='sales_order_allocations', + limit_choices_to={'part__salable': True}, + ) quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index c144316c70..bcec2c2bf1 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -374,10 +374,12 @@ class StockList(generics.ListCreateAPIView): allocated = str2bool(allocated) if allocated: - stock_list = stock_list.exclude(Q(sales_order_line=None)) + # Filter StockItem with either build allocations or sales order allocations + stock_list = stock_list.filter(Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False)) else: - stock_list = stock_list.filter(Q(sales_order_line=None)) - + # Filter StockItem without build allocations or sales order allocations + stock_list = stock_list.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) + # Do we wish to filter by "active parts" active = self.request.query_params.get('active', None) @@ -477,22 +479,10 @@ class StockList(generics.ListCreateAPIView): if manufacturer is not None: stock_list = stock_list.filter(supplier_part__manufacturer=manufacturer) - # Filter by sales order - sales_order = self.request.query_params.get('sales_order', None) - - if sales_order is not None: - try: - sales_order = SalesOrder.objects.get(pk=sales_order) - lines = [line.pk for line in sales_order.lines.all()] - stock_list = stock_list.filter(sales_order_line__in=lines) - except (SalesOrder.DoesNotExist, ValueError): - raise ValidationError({'sales_order': 'Invalid SalesOrder object specified'}) - # Also ensure that we pre-fecth all the related items stock_list = stock_list.prefetch_related( 'part', 'part__category', - 'sales_order_line__order', 'location' ) @@ -517,7 +507,7 @@ class StockList(generics.ListCreateAPIView): 'customer', 'belongs_to', 'build', - 'sales_order_line' + 'sales_order', ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 17dea77305..7a30b694b5 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -11,6 +11,8 @@ from django.core.exceptions import ValidationError from django.urls import reverse from django.db import models, transaction +from django.db.models import Sum +from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator from django.contrib.auth.models import User from django.db.models.signals import pre_delete @@ -29,7 +31,7 @@ from InvenTree.models import InvenTreeTree from InvenTree.fields import InvenTreeURLField from part.models import Part -from order.models import PurchaseOrder, SalesOrder +from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation class StockLocation(InvenTreeTree): @@ -391,7 +393,39 @@ class StockItem(MPTTModel): # TODO - For now this only checks if the StockItem is allocated to a SalesOrder # TODO - In future, once the "build" is working better, check this too - return self.sales_order_line is not None + if self.allocations.count() > 0: + return True + + if self.sales_order_allocations.count() > 0: + return True + + return False + + def build_allocation_count(self): + """ + Return the total quantity allocated to builds + """ + + query = self.allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) + + return query['q'] + + def sales_order_allocation_count(self): + """ + Return the total quantity allocated to SalesOrders + """ + + query = self.sales_order_allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) + + return query['q'] + + def allocation_count(self): + """ + Return the total quantity allocated to builds or orders + """ + + return self.build_allocation_count() + self.sales_order_allocation_count() + def can_delete(self): """ Can this stock item be deleted? It can NOT be deleted under the following circumstances: diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index e0176f5192..a2fbd5bb6a 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -67,6 +67,8 @@ class StockItemSerializer(InvenTreeModelSerializer): 'supplier_part', 'supplier_part__supplier', 'supplier_part__manufacturer', + 'allocations', + 'sales_order_allocations', 'location', 'part', 'tracking_info', diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index da002c2ac9..4e9bd81f0e 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -15,12 +15,19 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% block pre_content %} {% include 'stock/loc_link.html' with location=item.location %} -{% if item.sales_order_line %} +{% for allocation in item.sales_order_allocations.all %}
{% trans "This stock item is allocated to Sales Order" %} - {{ item.sales_order_line.order }} + {{ allcation.line.order }}
-{% endif %} +{% endfor %} + +{% for allocation in item.allocations.all %} +
+ {% trans "This stock item is allocated to Build" %} + {{ allocation.build }} +
+{% endfor %} {% if item.serialized %}