diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 15049a5456..5dd9c812aa 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,10 +12,13 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 20 +INVENTREE_API_VERSION = 21 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v21 -> 2021-12-04 + - Adds support for multiple "Shipments" against a SalesOrder + - Refactors process for stock allocation against a SalesOrder v20 -> 2021-12-03 - Adds ability to filter POLineItem endpoint by "base_part" diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 2431855d3c..2ea898f66c 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -423,7 +423,7 @@ class BuildAllocationSerializer(serializers.Serializer): Validation """ - super().validate(data) + data = super().validate(data) items = data.get('items', []) diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 3903dba685..489493fd06 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -222,4 +222,4 @@ }); } -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 502c63c084..07a8e0ad0a 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 +from .models import SalesOrderShipment, SalesOrderAllocation class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): @@ -137,6 +137,15 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin): ) +class SalesOrderShipmentAdmin(ImportExportModelAdmin): + + list_display = [ + 'order', + 'shipment_date', + 'reference', + ] + + class SalesOrderAllocationAdmin(ImportExportModelAdmin): list_display = ( @@ -152,4 +161,5 @@ admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) admin.site.register(SalesOrder, SalesOrderAdmin) admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) +admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin) admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index bdeb8ee4f6..1ae6ae0184 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -13,23 +13,17 @@ from rest_framework import generics from rest_framework import filters, status from rest_framework.response import Response +from company.models import SupplierPart + from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.helpers import str2bool from InvenTree.api import AttachmentMixin from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus +import order.models as models +import order.serializers as serializers + from part.models import Part -from company.models import SupplierPart - -from .models import PurchaseOrder, PurchaseOrderLineItem -from .models import PurchaseOrderAttachment -from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer - -from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation -from .models import SalesOrderAttachment -from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer -from .serializers import SalesOrderAllocationSerializer -from .serializers import POReceiveSerializer class POList(generics.ListCreateAPIView): @@ -39,8 +33,8 @@ class POList(generics.ListCreateAPIView): - POST: Create a new PurchaseOrder object """ - queryset = PurchaseOrder.objects.all() - serializer_class = POSerializer + queryset = models.PurchaseOrder.objects.all() + serializer_class = serializers.POSerializer def create(self, request, *args, **kwargs): """ @@ -77,7 +71,7 @@ class POList(generics.ListCreateAPIView): 'lines', ) - queryset = POSerializer.annotate_queryset(queryset) + queryset = serializers.POSerializer.annotate_queryset(queryset) return queryset @@ -106,9 +100,9 @@ class POList(generics.ListCreateAPIView): overdue = str2bool(overdue) if overdue: - queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER) + queryset = queryset.filter(models.PurchaseOrder.OVERDUE_FILTER) else: - queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER) + queryset = queryset.exclude(models.PurchaseOrder.OVERDUE_FILTER) # Special filtering for 'status' field status = params.get('status', None) @@ -142,7 +136,7 @@ class POList(generics.ListCreateAPIView): max_date = params.get('max_date', None) if min_date is not None and max_date is not None: - queryset = PurchaseOrder.filterByDate(queryset, min_date, max_date) + queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date) return queryset @@ -182,8 +176,8 @@ class POList(generics.ListCreateAPIView): class PODetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a PurchaseOrder object """ - queryset = PurchaseOrder.objects.all() - serializer_class = POSerializer + queryset = models.PurchaseOrder.objects.all() + serializer_class = serializers.POSerializer def get_serializer(self, *args, **kwargs): @@ -206,7 +200,7 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView): 'lines', ) - queryset = POSerializer.annotate_queryset(queryset) + queryset = serializers.POSerializer.annotate_queryset(queryset) return queryset @@ -224,9 +218,9 @@ class POReceive(generics.CreateAPIView): - A global location can also be specified """ - queryset = PurchaseOrderLineItem.objects.none() + queryset = models.PurchaseOrderLineItem.objects.none() - serializer_class = POReceiveSerializer + serializer_class = serializers.POReceiveSerializer def get_serializer_context(self): @@ -234,7 +228,7 @@ class POReceive(generics.CreateAPIView): # Pass the purchase order through to the serializer for validation try: - context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None)) + context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None)) except: pass @@ -249,7 +243,7 @@ class POLineItemFilter(rest_filters.FilterSet): """ class Meta: - model = PurchaseOrderLineItem + model = models.PurchaseOrderLineItem fields = [ 'order', 'part' @@ -283,15 +277,15 @@ class POLineItemList(generics.ListCreateAPIView): - POST: Create a new PurchaseOrderLineItem object """ - queryset = PurchaseOrderLineItem.objects.all() - serializer_class = POLineItemSerializer + queryset = models.PurchaseOrderLineItem.objects.all() + serializer_class = serializers.POLineItemSerializer filterset_class = POLineItemFilter def get_queryset(self, *args, **kwargs): queryset = super().get_queryset(*args, **kwargs) - queryset = POLineItemSerializer.annotate_queryset(queryset) + queryset = serializers.POLineItemSerializer.annotate_queryset(queryset) return queryset @@ -371,14 +365,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView): Detail API endpoint for PurchaseOrderLineItem object """ - queryset = PurchaseOrderLineItem.objects.all() - serializer_class = POLineItemSerializer + queryset = models.PurchaseOrderLineItem.objects.all() + serializer_class = serializers.POLineItemSerializer def get_queryset(self): queryset = super().get_queryset() - queryset = POLineItemSerializer.annotate_queryset(queryset) + queryset = serializers.POLineItemSerializer.annotate_queryset(queryset) return queryset @@ -388,8 +382,8 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin): API endpoint for listing (and creating) a SalesOrderAttachment (file upload) """ - queryset = SalesOrderAttachment.objects.all() - serializer_class = SOAttachmentSerializer + queryset = models.SalesOrderAttachment.objects.all() + serializer_class = serializers.SOAttachmentSerializer filter_backends = [ rest_filters.DjangoFilterBackend, @@ -405,8 +399,8 @@ class SOAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin) Detail endpoint for SalesOrderAttachment """ - queryset = SalesOrderAttachment.objects.all() - serializer_class = SOAttachmentSerializer + queryset = models.SalesOrderAttachment.objects.all() + serializer_class = serializers.SOAttachmentSerializer class SOList(generics.ListCreateAPIView): @@ -417,8 +411,8 @@ class SOList(generics.ListCreateAPIView): - POST: Create a new SalesOrder """ - queryset = SalesOrder.objects.all() - serializer_class = SalesOrderSerializer + queryset = models.SalesOrder.objects.all() + serializer_class = serializers.SalesOrderSerializer def create(self, request, *args, **kwargs): """ @@ -455,7 +449,7 @@ class SOList(generics.ListCreateAPIView): 'lines' ) - queryset = SalesOrderSerializer.annotate_queryset(queryset) + queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) return queryset @@ -475,9 +469,9 @@ class SOList(generics.ListCreateAPIView): outstanding = str2bool(outstanding) if outstanding: - queryset = queryset.filter(status__in=SalesOrderStatus.OPEN) + queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN) else: - queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN) + queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN) # Filter by 'overdue' status overdue = params.get('overdue', None) @@ -486,9 +480,9 @@ class SOList(generics.ListCreateAPIView): overdue = str2bool(overdue) if overdue: - queryset = queryset.filter(SalesOrder.OVERDUE_FILTER) + queryset = queryset.filter(models.SalesOrder.OVERDUE_FILTER) else: - queryset = queryset.exclude(SalesOrder.OVERDUE_FILTER) + queryset = queryset.exclude(models.SalesOrder.OVERDUE_FILTER) status = params.get('status', None) @@ -511,7 +505,7 @@ class SOList(generics.ListCreateAPIView): max_date = params.get('max_date', None) if min_date is not None and max_date is not None: - queryset = SalesOrder.filterByDate(queryset, min_date, max_date) + queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date) return queryset @@ -555,8 +549,8 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView): API endpoint for detail view of a SalesOrder object. """ - queryset = SalesOrder.objects.all() - serializer_class = SalesOrderSerializer + queryset = models.SalesOrder.objects.all() + serializer_class = serializers.SalesOrderSerializer def get_serializer(self, *args, **kwargs): @@ -575,7 +569,40 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView): queryset = queryset.prefetch_related('customer', 'lines') - queryset = SalesOrderSerializer.annotate_queryset(queryset) + queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) + + return queryset + + +class SOLineItemFilter(rest_filters.FilterSet): + """ + Custom filters for SOLineItemList endpoint + """ + + class Meta: + model = models.SalesOrderLineItem + fields = [ + 'order', + 'part', + ] + + completed = rest_filters.BooleanFilter(label='completed', method='filter_completed') + + def filter_completed(self, queryset, name, value): + """ + Filter by lines which are "completed" + + A line is completed when shipped >= quantity + """ + + value = str2bool(value) + + q = Q(shipped__gte=F('quantity')) + + if value: + queryset = queryset.filter(q) + else: + queryset = queryset.exclude(q) return queryset @@ -585,8 +612,9 @@ class SOLineItemList(generics.ListCreateAPIView): API endpoint for accessing a list of SalesOrderLineItem objects. """ - queryset = SalesOrderLineItem.objects.all() - serializer_class = SOLineItemSerializer + queryset = models.SalesOrderLineItem.objects.all() + serializer_class = serializers.SOLineItemSerializer + filterset_class = SOLineItemFilter def get_serializer(self, *args, **kwargs): @@ -645,8 +673,80 @@ class SOLineItemList(generics.ListCreateAPIView): class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a SalesOrderLineItem object """ - queryset = SalesOrderLineItem.objects.all() - serializer_class = SOLineItemSerializer + queryset = models.SalesOrderLineItem.objects.all() + serializer_class = serializers.SOLineItemSerializer + + +class SalesOrderComplete(generics.CreateAPIView): + """ + API endpoint for manually marking a SalesOrder as "complete". + """ + + queryset = models.SalesOrder.objects.all() + serializer_class = serializers.SalesOrderCompleteSerializer + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + ctx['request'] = self.request + + try: + ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return ctx + + +class SalesOrderAllocateSerials(generics.CreateAPIView): + """ + API endpoint to allocation stock items against a SalesOrder, + by specifying serial numbers. + """ + + queryset = models.SalesOrder.objects.none() + serializer_class = serializers.SOSerialAllocationSerializer + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + # Pass through the SalesOrder object to the serializer + try: + ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + ctx['request'] = self.request + + return ctx + + +class SalesOrderAllocate(generics.CreateAPIView): + """ + API endpoint to allocate stock items against a SalesOrder + + - The SalesOrder is specified in the URL + - See the SOShipmentAllocationSerializer class + """ + + queryset = models.SalesOrder.objects.none() + serializer_class = serializers.SOShipmentAllocationSerializer + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + # Pass through the SalesOrder object to the serializer + try: + ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + ctx['request'] = self.request + + return ctx class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): @@ -654,17 +754,17 @@ class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): API endpoint for detali view of a SalesOrderAllocation object """ - queryset = SalesOrderAllocation.objects.all() - serializer_class = SalesOrderAllocationSerializer + queryset = models.SalesOrderAllocation.objects.all() + serializer_class = serializers.SalesOrderAllocationSerializer -class SOAllocationList(generics.ListCreateAPIView): +class SOAllocationList(generics.ListAPIView): """ API endpoint for listing SalesOrderAllocation objects """ - queryset = SalesOrderAllocation.objects.all() - serializer_class = SalesOrderAllocationSerializer + queryset = models.SalesOrderAllocation.objects.all() + serializer_class = serializers.SalesOrderAllocationSerializer def get_serializer(self, *args, **kwargs): @@ -722,13 +822,87 @@ class SOAllocationList(generics.ListCreateAPIView): ] +class SOShipmentFilter(rest_filters.FilterSet): + """ + Custom filterset for the SOShipmentList endpoint + """ + + shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped') + + def filter_shipped(self, queryset, name, value): + + value = str2bool(value) + + if value: + queryset = queryset.exclude(shipment_date=None) + else: + queryset = queryset.filter(shipment_date=None) + + return queryset + + class Meta: + model = models.SalesOrderShipment + fields = [ + 'order', + ] + + +class SOShipmentList(generics.ListCreateAPIView): + """ + API list endpoint for SalesOrderShipment model + """ + + queryset = models.SalesOrderShipment.objects.all() + serializer_class = serializers.SalesOrderShipmentSerializer + filterset_class = SOShipmentFilter + + filter_backends = [ + rest_filters.DjangoFilterBackend, + ] + + +class SOShipmentDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API detail endpooint for SalesOrderShipment model + """ + + queryset = models.SalesOrderShipment.objects.all() + serializer_class = serializers.SalesOrderShipmentSerializer + + +class SOShipmentComplete(generics.CreateAPIView): + """ + API endpoint for completing (shipping) a SalesOrderShipment + """ + + queryset = models.SalesOrderShipment.objects.all() + serializer_class = serializers.SalesOrderShipmentCompleteSerializer + + def get_serializer_context(self): + """ + Pass the request object to the serializer + """ + + ctx = super().get_serializer_context() + ctx['request'] = self.request + + try: + ctx['shipment'] = models.SalesOrderShipment.objects.get( + pk=self.kwargs.get('pk', None) + ) + except: + pass + + return ctx + + class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload) """ - queryset = PurchaseOrderAttachment.objects.all() - serializer_class = POAttachmentSerializer + queryset = models.PurchaseOrderAttachment.objects.all() + serializer_class = serializers.POAttachmentSerializer filter_backends = [ rest_filters.DjangoFilterBackend, @@ -744,8 +918,8 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin) Detail endpoint for a PurchaseOrderAttachment """ - queryset = PurchaseOrderAttachment.objects.all() - serializer_class = POAttachmentSerializer + queryset = models.PurchaseOrderAttachment.objects.all() + serializer_class = serializers.POAttachmentSerializer order_api_urls = [ @@ -782,7 +956,23 @@ order_api_urls = [ url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'), ])), - url(r'^(?P\d+)/$', SODetail.as_view(), name='api-so-detail'), + url(r'^shipment/', include([ + url(r'^(?P\d+)/', include([ + url(r'^ship/$', SOShipmentComplete.as_view(), name='api-so-shipment-ship'), + url(r'^.*$', SOShipmentDetail.as_view(), name='api-so-shipment-detail'), + ])), + url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'), + ])), + + # Sales order detail view + url(r'^(?P\d+)/', include([ + url(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'), + url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), + url(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), + url(r'^.*$', SODetail.as_view(), name='api-so-detail'), + ])), + + # Sales order list view url(r'^.*$', SOList.as_view(), name='api-so-list'), ])), diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 6f0bc43c46..3eb5566a1e 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -15,10 +15,8 @@ from InvenTree.helpers import clean_decimal from common.forms import MatchItemForm -import part.models - from .models import PurchaseOrder -from .models import SalesOrder, SalesOrderLineItem +from .models import SalesOrder class IssuePurchaseOrderForm(HelperForm): @@ -65,57 +63,6 @@ class CancelSalesOrderForm(HelperForm): ] -class ShipSalesOrderForm(HelperForm): - - confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Ship order')) - - class Meta: - model = SalesOrder - fields = [ - 'confirm', - ] - - -class AllocateSerialsToSalesOrderForm(forms.Form): - """ - Form for assigning stock to a sales order, - by serial number lookup - - TODO: Refactor this form / view to use the new API forms interface - """ - - line = forms.ModelChoiceField( - queryset=SalesOrderLineItem.objects.all(), - ) - - part = forms.ModelChoiceField( - queryset=part.models.Part.objects.all(), - ) - - serials = forms.CharField( - label=_("Serial Numbers"), - required=True, - help_text=_('Enter stock item serial numbers'), - ) - - quantity = forms.IntegerField( - label=_('Quantity'), - required=True, - help_text=_('Enter quantity of stock items'), - initial=1, - min_value=1 - ) - - class Meta: - - fields = [ - 'line', - 'part', - 'serials', - 'quantity', - ] - - class OrderMatchItemForm(MatchItemForm): """ Override MatchItemForm fields """ diff --git a/InvenTree/order/migrations/0053_salesordershipment.py b/InvenTree/order/migrations/0053_salesordershipment.py new file mode 100644 index 0000000000..f36895c5ee --- /dev/null +++ b/InvenTree/order/migrations/0053_salesordershipment.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.5 on 2021-10-25 02:08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +import order.models + +import markdownx.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('order', '0053_auto_20211128_0151'), + ] + + operations = [ + migrations.CreateModel( + name='SalesOrderShipment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('shipment_date', models.DateField(blank=True, help_text='Date of shipment', null=True, verbose_name='Shipment Date')), + ('reference', models.CharField(default='1', help_text='Shipment reference', max_length=100, verbose_name='Reference')), + ('notes', markdownx.models.MarkdownxField(blank=True, help_text='Shipment notes', verbose_name='Notes')), + ('checked_by', models.ForeignKey(blank=True, help_text='User who checked this shipment', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Checked By')), + ('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='shipments', to='order.salesorder', verbose_name='Order')), + ], + ), + ] diff --git a/InvenTree/order/migrations/0054_salesorderallocation_shipment.py b/InvenTree/order/migrations/0054_salesorderallocation_shipment.py new file mode 100644 index 0000000000..cfb74dd859 --- /dev/null +++ b/InvenTree/order/migrations/0054_salesorderallocation_shipment.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.5 on 2021-10-25 06:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0053_salesordershipment'), + ] + + operations = [ + migrations.AddField( + model_name='salesorderallocation', + name='shipment', + field=models.ForeignKey(blank=True, help_text='Sales order shipment reference', null=True, on_delete=django.db.models.deletion.CASCADE, to='order.salesordershipment', verbose_name='Shipment'), + ), + ] diff --git a/InvenTree/order/migrations/0055_auto_20211025_0645.py b/InvenTree/order/migrations/0055_auto_20211025_0645.py new file mode 100644 index 0000000000..d45e92aead --- /dev/null +++ b/InvenTree/order/migrations/0055_auto_20211025_0645.py @@ -0,0 +1,92 @@ +# Generated by Django 3.2.5 on 2021-10-25 06:45 + +from django.db import migrations + + +from InvenTree.status_codes import SalesOrderStatus + + +def add_shipment(apps, schema_editor): + """ + Create a SalesOrderShipment for each existing SalesOrder instance. + + Any "allocations" are marked against that shipment. + + For each existing SalesOrder instance, we create a default SalesOrderShipment, + and associate each SalesOrderAllocation with this shipment + """ + + Allocation = apps.get_model('order', 'salesorderallocation') + SalesOrder = apps.get_model('order', 'salesorder') + Shipment = apps.get_model('order', 'salesordershipment') + + n = 0 + + for order in SalesOrder.objects.all(): + + """ + We only create an automatic shipment for "PENDING" orders, + as SalesOrderAllocations were historically deleted for "SHIPPED" or "CANCELLED" orders + """ + + allocations = Allocation.objects.filter( + line__order=order + ) + + if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING: + continue + + # Create a new Shipment instance against this order + shipment = Shipment.objects.create( + order=order, + ) + + if order.status == SalesOrderStatus.SHIPPED: + shipment.shipment_date = order.shipment_date + + shipment.save() + + # Iterate through each allocation associated with this order + for allocation in allocations: + allocation.shipment = shipment + allocation.save() + + n += 1 + + if n > 0: + print(f"\nCreated SalesOrderShipment for {n} SalesOrder instances") + + +def reverse_add_shipment(apps, schema_editor): + """ + Reverse the migration, delete and SalesOrderShipment instances + """ + + Allocation = apps.get_model('order', 'salesorderallocation') + + # First, ensure that all SalesOrderAllocation objects point to a null shipment + for allocation in Allocation.objects.exclude(shipment=None): + allocation.shipment = None + allocation.save() + + SOS = apps.get_model('order', 'salesordershipment') + + n = SOS.objects.count() + + print(f"Deleting {n} SalesOrderShipment instances") + + SOS.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0054_salesorderallocation_shipment'), + ] + + operations = [ + migrations.RunPython( + add_shipment, + reverse_code=reverse_add_shipment, + ) + ] diff --git a/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py b/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py new file mode 100644 index 0000000000..340a8d4ab5 --- /dev/null +++ b/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.5 on 2021-10-25 11:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0055_auto_20211025_0645'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorderallocation', + name='shipment', + field=models.ForeignKey(help_text='Sales order shipment reference', on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.salesordershipment', verbose_name='Shipment'), + ), + ] diff --git a/InvenTree/order/migrations/0057_salesorderlineitem_shipped.py b/InvenTree/order/migrations/0057_salesorderlineitem_shipped.py new file mode 100644 index 0000000000..e11eb62cbb --- /dev/null +++ b/InvenTree/order/migrations/0057_salesorderlineitem_shipped.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.5 on 2021-11-26 12:06 + +import InvenTree.fields +import django.core.validators +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0056_alter_salesorderallocation_shipment'), + ] + + operations = [ + migrations.AddField( + model_name='salesorderlineitem', + name='shipped', + field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=0, help_text='Shipped quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Shipped'), + ), + ] diff --git a/InvenTree/order/migrations/0058_auto_20211126_1210.py b/InvenTree/order/migrations/0058_auto_20211126_1210.py new file mode 100644 index 0000000000..2736416e66 --- /dev/null +++ b/InvenTree/order/migrations/0058_auto_20211126_1210.py @@ -0,0 +1,62 @@ +# Generated by Django 3.2.5 on 2021-11-26 12:10 + +from django.db import migrations + +from InvenTree.status_codes import SalesOrderStatus + + +def calculate_shipped_quantity(apps, schema_editor): + """ + In migration 0057 we added a new field 'shipped' to the SalesOrderLineItem model. + + This field is used to record the number of items shipped, + even if the actual stock items get deleted from the database. + + For existing orders in the database, we calculate this as follows: + + - If the order is "shipped" then we use the total quantity + - Otherwise, we use the "fulfilled" calculated quantity + + """ + + StockItem = apps.get_model('stock', 'stockitem') + SalesOrderLineItem = apps.get_model('order', 'salesorderlineitem') + + for item in SalesOrderLineItem.objects.all(): + + if item.order.status == SalesOrderStatus.SHIPPED: + item.shipped = item.quantity + else: + # Calculate total stock quantity of items allocated to this order? + items = StockItem.objects.filter( + sales_order=item.order, + part=item.part + ) + + q = sum([item.quantity for item in items]) + + item.shipped = q + + item.save() + + +def reverse_calculate_shipped_quantity(apps, schema_editor): + """ + Provided only for reverse migration compatibility. + This function does nothing. + """ + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0057_salesorderlineitem_shipped'), + ] + + operations = [ + migrations.RunPython( + calculate_shipped_quantity, + reverse_code=reverse_calculate_shipped_quantity + ) + ] diff --git a/InvenTree/order/migrations/0059_salesordershipment_tracking_number.py b/InvenTree/order/migrations/0059_salesordershipment_tracking_number.py new file mode 100644 index 0000000000..473761fb4a --- /dev/null +++ b/InvenTree/order/migrations/0059_salesordershipment_tracking_number.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-11-29 11:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0058_auto_20211126_1210'), + ] + + operations = [ + migrations.AddField( + model_name='salesordershipment', + name='tracking_number', + field=models.CharField(blank=True, help_text='Shipment tracking information', max_length=100, verbose_name='Tracking Number'), + ), + ] diff --git a/InvenTree/order/migrations/0060_auto_20211129_1339.py b/InvenTree/order/migrations/0060_auto_20211129_1339.py new file mode 100644 index 0000000000..2166ec45ad --- /dev/null +++ b/InvenTree/order/migrations/0060_auto_20211129_1339.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.5 on 2021-11-29 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0059_salesordershipment_tracking_number'), + ] + + operations = [ + migrations.AlterField( + model_name='salesordershipment', + name='reference', + field=models.CharField(default='1', help_text='Shipment number', max_length=100, verbose_name='Shipment'), + ), + migrations.AlterUniqueTogether( + name='salesordershipment', + unique_together={('order', 'reference')}, + ), + ] diff --git a/InvenTree/order/migrations/0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339.py b/InvenTree/order/migrations/0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339.py new file mode 100644 index 0000000000..e844b0fc23 --- /dev/null +++ b/InvenTree/order/migrations/0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.5 on 2021-12-02 13:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0054_auto_20211201_2139'), + ('order', '0060_auto_20211129_1339'), + ] + + operations = [ + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index b8fdd3870c..3a7d39d7b5 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -107,45 +107,6 @@ class Order(ReferenceIndexingMixin): responsible: User (or group) responsible for managing the order """ - @classmethod - def getNextOrderNumber(cls): - """ - Try to predict the next order-number - """ - - if cls.objects.count() == 0: - return None - - # We will assume that the latest pk has the highest PO number - order = cls.objects.last() - ref = order.reference - - if not ref: - return None - - tries = set() - - tries.add(ref) - - while 1: - new_ref = increment(ref) - - print("Reference:", new_ref) - - if new_ref in tries: - # We are in a looping situation - simply return the original one - return ref - - # Check that the new ref does not exist in the database - if cls.objects.filter(reference=new_ref).exists(): - tries.add(new_ref) - new_ref = increment(new_ref) - - else: - break - - return new_ref - def save(self, *args, **kwargs): self.rebuild_reference_field() @@ -402,11 +363,30 @@ class PurchaseOrder(Order): return self.lines.filter(quantity__gt=F('received')) + def completed_line_items(self): + """ + Return a list of completed line items against this order + """ + return self.lines.filter(quantity__lte=F('received')) + + @property + def line_count(self): + return self.lines.count() + + @property + def completed_line_count(self): + + return self.completed_line_items().count() + + @property + def pending_line_count(self): + return self.pending_line_items().count() + @property def is_complete(self): """ Return True if all line items have been received """ - return self.pending_line_items().count() == 0 + return self.lines.count() > 0 and self.pending_line_items().count() == 0 @transaction.atomic def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs): @@ -606,6 +586,16 @@ class SalesOrder(Order): def is_pending(self): return self.status == SalesOrderStatus.PENDING + @property + def stock_allocations(self): + """ + Return a queryset containing all allocations for this order + """ + + return SalesOrderAllocation.objects.filter( + line__in=[line.pk for line in self.lines.all()] + ) + def is_fully_allocated(self): """ Return True if all line items are fully allocated """ @@ -624,29 +614,55 @@ class SalesOrder(Order): return False - @transaction.atomic - def ship_order(self, user): - """ Mark this order as 'shipped' """ + def is_completed(self): + """ + Check if this order is "shipped" (all line items delivered), + """ - # The order can only be 'shipped' if the current status is PENDING - if not self.status == SalesOrderStatus.PENDING: - raise ValidationError({'status': _("SalesOrder cannot be shipped as it is not currently pending")}) + return self.lines.count() > 0 and all([line.is_completed() for line in self.lines.all()]) - # Complete the allocation for each allocated StockItem - for line in self.lines.all(): - for allocation in line.allocations.all(): - allocation.complete_allocation(user) + def can_complete(self, raise_error=False): + """ + Test if this SalesOrder can be completed. - # Remove the allocation from the database once it has been 'fulfilled' - if allocation.item.sales_order == self: - allocation.delete() - else: - raise ValidationError("Could not complete order - allocation item not fulfilled") + Throws a ValidationError if cannot be completed. + """ + + # Order without line items cannot be completed + if self.lines.count() == 0: + if raise_error: + raise ValidationError(_('Order cannot be completed as no parts have been assigned')) + + # Only a PENDING order can be marked as SHIPPED + elif self.status != SalesOrderStatus.PENDING: + if raise_error: + raise ValidationError(_('Only a pending order can be marked as complete')) + + elif self.pending_shipment_count > 0: + if raise_error: + raise ValidationError(_("Order cannot be completed as there are incomplete shipments")) + + elif self.pending_line_count > 0: + if raise_error: + raise ValidationError(_("Order cannot be completed as there are incomplete line items")) + + else: + return True + + return False + + def complete_order(self, user): + """ + Mark this order as "complete" + """ + + if not self.can_complete(): + return False - # Ensure the order status is marked as "Shipped" self.status = SalesOrderStatus.SHIPPED - self.shipment_date = datetime.now().date() self.shipped_by = user + self.shipment_date = datetime.now() + self.save() return True @@ -682,6 +698,55 @@ class SalesOrder(Order): return True + @property + def line_count(self): + return self.lines.count() + + def completed_line_items(self): + """ + Return a queryset of the completed line items for this order + """ + return self.lines.filter(shipped__gte=F('quantity')) + + def pending_line_items(self): + """ + Return a queryset of the pending line items for this order + """ + return self.lines.filter(shipped__lt=F('quantity')) + + @property + def completed_line_count(self): + return self.completed_line_items().count() + + @property + def pending_line_count(self): + return self.pending_line_items().count() + + def completed_shipments(self): + """ + Return a queryset of the completed shipments for this order + """ + return self.shipments.exclude(shipment_date=None) + + def pending_shipments(self): + """ + Return a queryset of the pending shipments for this order + """ + + return self.shipments.filter(shipment_date=None) + + @property + def shipment_count(self): + return self.shipments.count() + + @property + def completed_shipment_count(self): + return self.completed_shipments().count() + + @property + def pending_shipment_count(self): + return self.pending_shipments().count() + class PurchaseOrderAttachment(InvenTreeAttachment): """ @@ -815,13 +880,15 @@ class PurchaseOrderLineItem(OrderLineItem): ) def get_destination(self): - """Show where the line item is or should be placed""" - # NOTE: If a line item gets split when recieved, only an arbitrary - # stock items location will be reported as the location for the - # entire line. - for stock in stock_models.StockItem.objects.filter( - supplier_part=self.part, purchase_order=self.order - ): + """ + Show where the line item is or should be placed + + NOTE: If a line item gets split when recieved, only an arbitrary + stock items location will be reported as the location for the + entire line. + """ + + for stock in stock_models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order): if stock.location: return stock.location if self.destination: @@ -843,6 +910,7 @@ class SalesOrderLineItem(OrderLineItem): order: Link to the SalesOrder that this line item belongs to part: Link to a Part object (may be null) sale_price: The unit sale price for this OrderLineItem + shipped: The number of items which have actually shipped against this line item """ @staticmethod @@ -867,6 +935,14 @@ class SalesOrderLineItem(OrderLineItem): help_text=_('Unit sale price'), ) + shipped = RoundingDecimalField( + verbose_name=_('Shipped'), + help_text=_('Shipped quantity'), + default=0, + max_digits=15, decimal_places=5, + validators=[MinValueValidator(0)] + ) + class Meta: unique_together = [ ] @@ -902,6 +978,130 @@ class SalesOrderLineItem(OrderLineItem): """ Return True if this line item is over allocated """ return self.allocated_quantity() > self.quantity + def is_completed(self): + """ + Return True if this line item is completed (has been fully shipped) + """ + + return self.shipped >= self.quantity + + +class SalesOrderShipment(models.Model): + """ + The SalesOrderShipment model represents a physical shipment made against a SalesOrder. + + - Points to a single SalesOrder object + - Multiple SalesOrderAllocation objects point to a particular SalesOrderShipment + - When a given SalesOrderShipment is "shipped", stock items are removed from stock + + Attributes: + order: SalesOrder reference + shipment_date: Date this shipment was "shipped" (or null) + checked_by: User reference field indicating who checked this order + reference: Custom reference text for this shipment (e.g. consignment number?) + notes: Custom notes field for this shipment + """ + + class Meta: + # Shipment reference must be unique for a given sales order + unique_together = [ + 'order', 'reference', + ] + + @staticmethod + def get_api_url(): + return reverse('api-so-shipment-list') + + order = models.ForeignKey( + SalesOrder, + on_delete=models.CASCADE, + blank=False, null=False, + related_name='shipments', + verbose_name=_('Order'), + help_text=_('Sales Order'), + ) + + shipment_date = models.DateField( + null=True, blank=True, + verbose_name=_('Shipment Date'), + help_text=_('Date of shipment'), + ) + + checked_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, null=True, + verbose_name=_('Checked By'), + help_text=_('User who checked this shipment'), + related_name='+', + ) + + reference = models.CharField( + max_length=100, + blank=False, + verbose_name=('Shipment'), + help_text=_('Shipment number'), + default='1', + ) + + notes = MarkdownxField( + blank=True, + verbose_name=_('Notes'), + help_text=_('Shipment notes'), + ) + + tracking_number = models.CharField( + max_length=100, + blank=True, + unique=False, + verbose_name=_('Tracking Number'), + help_text=_('Shipment tracking information'), + ) + + def is_complete(self): + return self.shipment_date is not None + + def check_can_complete(self): + + if self.shipment_date: + # Shipment has already been sent! + raise ValidationError(_("Shipment has already been sent")) + + if self.allocations.count() == 0: + raise ValidationError(_("Shipment has no allocated stock items")) + + @transaction.atomic + def complete_shipment(self, user, **kwargs): + """ + Complete this particular shipment: + + 1. Update any stock items associated with this shipment + 2. Update the "shipped" quantity of all associated line items + 3. Set the "shipment_date" to now + """ + + # Check if the shipment can be completed (throw error if not) + self.check_can_complete() + + allocations = self.allocations.all() + + # Iterate through each stock item assigned to this shipment + for allocation in allocations: + # Mark the allocation as "complete" + allocation.complete_allocation(user) + + # Update the "shipment" date + self.shipment_date = datetime.now() + self.shipped_by = user + + # Was a tracking number provided? + tracking_number = kwargs.get('tracking_number', None) + + if tracking_number is not None: + self.tracking_number = tracking_number + + self.save() + class SalesOrderAllocation(models.Model): """ @@ -911,6 +1111,7 @@ class SalesOrderAllocation(models.Model): Attributes: line: SalesOrderLineItem reference + shipment: SalesOrderShipment reference item: StockItem reference quantity: Quantity to take from the StockItem @@ -966,6 +1167,10 @@ class SalesOrderAllocation(models.Model): if self.item.serial and not self.quantity == 1: errors['quantity'] = _('Quantity must be 1 for serialized stock item') + if self.line.order != self.shipment.order: + errors['line'] = _('Sales order does not match shipment') + errors['shipment'] = _('Shipment does not match sales order') + if len(errors) > 0: raise ValidationError(errors) @@ -973,7 +1178,16 @@ class SalesOrderAllocation(models.Model): SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), - related_name='allocations') + related_name='allocations' + ) + + shipment = models.ForeignKey( + SalesOrderShipment, + on_delete=models.CASCADE, + related_name='allocations', + verbose_name=_('Shipment'), + help_text=_('Sales order shipment reference'), + ) item = models.ForeignKey( 'stock.StockItem', @@ -1022,6 +1236,10 @@ class SalesOrderAllocation(models.Model): user=user ) + # Update the 'shipped' quantity + self.line.shipped += self.quantity + self.line.save() + # Update our own reference to the StockItem # (It may have changed if the stock was split) self.item = item diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 060fd9ff1f..32e50943b1 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -21,21 +21,19 @@ from common.settings import currency_code_mappings from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from InvenTree.serializers import InvenTreeAttachmentSerializer +from InvenTree.helpers import normalize, extract_serial_numbers from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import ReferenceIndexingSerializerMixin from InvenTree.status_codes import StockStatus +import order.models + from part.serializers import PartBriefSerializer import stock.models -from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer - -from .models import PurchaseOrder, PurchaseOrderLineItem -from .models import PurchaseOrderAttachment, SalesOrderAttachment -from .models import SalesOrder, SalesOrderLineItem -from .models import SalesOrderAllocation +import stock.serializers from users.serializers import OwnerSerializer @@ -68,7 +66,7 @@ class POSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): queryset = queryset.annotate( overdue=Case( When( - PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), ), default=Value(False, output_field=BooleanField()) ) @@ -89,7 +87,7 @@ class POSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False) class Meta: - model = PurchaseOrder + model = order.models.PurchaseOrder fields = [ 'pk', @@ -168,7 +166,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): purchase_price_string = serializers.CharField(source='purchase_price', read_only=True) - destination_detail = LocationBriefSerializer(source='get_destination', read_only=True) + destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True) purchase_price_currency = serializers.ChoiceField( choices=currency_code_mappings(), @@ -178,7 +176,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): order_detail = POSerializer(source='order', read_only=True, many=False) class Meta: - model = PurchaseOrderLineItem + model = order.models.PurchaseOrderLineItem fields = [ 'pk', @@ -206,7 +204,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): """ line_item = serializers.PrimaryKeyRelatedField( - queryset=PurchaseOrderLineItem.objects.all(), + queryset=order.models.PurchaseOrderLineItem.objects.all(), many=False, allow_null=False, required=True, @@ -386,7 +384,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer): """ class Meta: - model = PurchaseOrderAttachment + model = order.models.PurchaseOrderAttachment fields = [ 'pk', @@ -433,7 +431,7 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria queryset = queryset.annotate( overdue=Case( When( - SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), ), default=Value(False, output_field=BooleanField()) ) @@ -452,7 +450,7 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria reference = serializers.CharField(required=True) class Meta: - model = SalesOrder + model = order.models.SalesOrder fields = [ 'pk', @@ -495,13 +493,15 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): # Extra detail fields order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True) part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) - item_detail = StockItemSerializer(source='item', many=False, read_only=True) - location_detail = LocationSerializer(source='item.location', many=False, read_only=True) + item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True) + location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True) + + shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True) def __init__(self, *args, **kwargs): order_detail = kwargs.pop('order_detail', False) - part_detail = kwargs.pop('part_detail', False) + part_detail = kwargs.pop('part_detail', True) item_detail = kwargs.pop('item_detail', False) location_detail = kwargs.pop('location_detail', False) @@ -520,7 +520,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): self.fields.pop('location_detail') class Meta: - model = SalesOrderAllocation + model = order.models.SalesOrderAllocation fields = [ 'pk', @@ -535,6 +535,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): 'order_detail', 'part', 'part_detail', + 'shipment', + 'shipment_date', ] @@ -565,7 +567,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer): quantity = InvenTreeDecimalField() allocated = serializers.FloatField(source='allocated_quantity', read_only=True) - fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) + + shipped = InvenTreeDecimalField(read_only=True) sale_price = InvenTreeMoneySerializer( allow_null=True @@ -579,14 +582,13 @@ class SOLineItemSerializer(InvenTreeModelSerializer): ) class Meta: - model = SalesOrderLineItem + model = order.models.SalesOrderLineItem fields = [ 'pk', 'allocated', 'allocations', 'quantity', - 'fulfilled', 'reference', 'notes', 'order', @@ -596,16 +598,421 @@ class SOLineItemSerializer(InvenTreeModelSerializer): 'sale_price', 'sale_price_currency', 'sale_price_string', + 'shipped', ] +class SalesOrderShipmentSerializer(InvenTreeModelSerializer): + """ + Serializer for the SalesOrderShipment class + """ + + allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) + + order_detail = SalesOrderSerializer(source='order', read_only=True, many=False) + + class Meta: + model = order.models.SalesOrderShipment + + fields = [ + 'pk', + 'order', + 'order_detail', + 'allocations', + 'shipment_date', + 'checked_by', + 'reference', + 'tracking_number', + 'notes', + ] + + +class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer): + """ + Serializer for completing (shipping) a SalesOrderShipment + """ + + class Meta: + model = order.models.SalesOrderShipment + + fields = [ + 'tracking_number', + ] + + def validate(self, data): + + data = super().validate(data) + + shipment = self.context.get('shipment', None) + + if not shipment: + raise ValidationError(_("No shipment details provided")) + + shipment.check_can_complete() + + return data + + def save(self): + + shipment = self.context.get('shipment', None) + + if not shipment: + return + + data = self.validated_data + + request = self.context['request'] + user = request.user + + # Extract provided tracking number (optional) + tracking_number = data.get('tracking_number', None) + + shipment.complete_shipment(user, tracking_number=tracking_number) + + +class SOShipmentAllocationItemSerializer(serializers.Serializer): + """ + A serializer for allocating a single stock-item against a SalesOrder shipment + """ + + class Meta: + fields = [ + 'line_item', + 'stock_item', + 'quantity', + ] + + line_item = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderLineItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + def validate_line_item(self, line_item): + + order = self.context['order'] + + # Ensure that the line item points to the correct order + if line_item.order != order: + raise ValidationError(_("Line item is not associated with this order")) + + return line_item + + stock_item = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True + ) + + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be positive")) + + return quantity + + def validate(self, data): + + data = super().validate(data) + + stock_item = data['stock_item'] + quantity = data['quantity'] + + if stock_item.serialized and quantity != 1: + raise ValidationError({ + 'quantity': _("Quantity must be 1 for serialized stock item"), + }) + + q = normalize(stock_item.unallocated_quantity()) + + if quantity > q: + raise ValidationError({ + 'quantity': _(f"Available quantity ({q}) exceeded") + }) + + return data + + +class SalesOrderCompleteSerializer(serializers.Serializer): + """ + DRF serializer for manually marking a sales order as complete + """ + + def validate(self, data): + + data = super().validate(data) + + order = self.context['order'] + + order.can_complete(raise_error=True) + + return data + + def save(self): + + request = self.context['request'] + order = self.context['order'] + + user = getattr(request, 'user', None) + + order.complete_order(user) + + +class SOSerialAllocationSerializer(serializers.Serializer): + """ + DRF serializer for allocation of serial numbers against a sales order / shipment + """ + + class Meta: + fields = [ + 'line_item', + 'quantity', + 'serial_numbers', + 'shipment', + ] + + line_item = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderLineItem.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Line Item'), + ) + + def validate_line_item(self, line_item): + """ + Ensure that the line_item is valid + """ + + order = self.context['order'] + + # Ensure that the line item points to the correct order + if line_item.order != order: + raise ValidationError(_("Line item is not associated with this order")) + + return line_item + + quantity = serializers.IntegerField( + min_value=1, + required=True, + allow_null=False, + label=_('Quantity'), + ) + + serial_numbers = serializers.CharField( + label=_("Serial Numbers"), + help_text=_("Enter serial numbers to allocate"), + required=True, + allow_blank=False, + ) + + shipment = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderShipment.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Shipment'), + ) + + def validate_shipment(self, shipment): + """ + Validate the shipment: + + - Must point to the same order + - Must not be shipped + """ + + order = self.context['order'] + + if shipment.shipment_date is not None: + raise ValidationError(_("Shipment has already been shipped")) + + if shipment.order != order: + raise ValidationError(_("Shipment is not associated with this order")) + + return shipment + + def validate(self, data): + """ + Validation for the serializer: + + - Ensure the serial_numbers and quantity fields match + - Check that all serial numbers exist + - Check that the serial numbers are not yet allocated + """ + + data = super().validate(data) + + line_item = data['line_item'] + quantity = data['quantity'] + serial_numbers = data['serial_numbers'] + + part = line_item.part + + try: + data['serials'] = extract_serial_numbers(serial_numbers, quantity) + except DjangoValidationError as e: + raise ValidationError({ + 'serial_numbers': e.messages, + }) + + serials_not_exist = [] + serials_allocated = [] + stock_items_to_allocate = [] + + for serial in data['serials']: + items = stock.models.StockItem.objects.filter( + part=part, + serial=serial, + quantity=1, + ) + + if not items.exists(): + serials_not_exist.append(str(serial)) + continue + + stock_item = items[0] + + if stock_item.unallocated_quantity() == 1: + stock_items_to_allocate.append(stock_item) + else: + serials_allocated.append(str(serial)) + + if len(serials_not_exist) > 0: + + error_msg = _("No match found for the following serial numbers") + error_msg += ": " + error_msg += ",".join(serials_not_exist) + + raise ValidationError({ + 'serial_numbers': error_msg + }) + + if len(serials_allocated) > 0: + + error_msg = _("The following serial numbers are already allocated") + error_msg += ": " + error_msg += ",".join(serials_allocated) + + raise ValidationError({ + 'serial_numbers': error_msg, + }) + + data['stock_items'] = stock_items_to_allocate + + return data + + def save(self): + + data = self.validated_data + + line_item = data['line_item'] + stock_items = data['stock_items'] + shipment = data['shipment'] + + with transaction.atomic(): + for stock_item in stock_items: + # Create a new SalesOrderAllocation + order.models.SalesOrderAllocation.objects.create( + line=line_item, + item=stock_item, + quantity=1, + shipment=shipment + ) + + +class SOShipmentAllocationSerializer(serializers.Serializer): + """ + DRF serializer for allocation of stock items against a sales order / shipment + """ + + class Meta: + fields = [ + 'items', + 'shipment', + ] + + items = SOShipmentAllocationItemSerializer(many=True) + + shipment = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderShipment.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Shipment'), + ) + + def validate_shipment(self, shipment): + """ + Run validation against the provided shipment instance + """ + + order = self.context['order'] + + if shipment.shipment_date is not None: + raise ValidationError(_("Shipment has already been shipped")) + + if shipment.order != order: + raise ValidationError(_("Shipment is not associated with this order")) + + return shipment + + def validate(self, data): + """ + Serializer validation + """ + + data = super().validate(data) + + # Extract SalesOrder from serializer context + # order = self.context['order'] + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError(_('Allocation items must be provided')) + + return data + + def save(self): + """ + Perform the allocation of items against this order + """ + + data = self.validated_data + + items = data['items'] + shipment = data['shipment'] + + with transaction.atomic(): + for entry in items: + # Create a new SalesOrderAllocation + order.models.SalesOrderAllocation.objects.create( + line=entry.get('line_item'), + item=entry.get('stock_item'), + quantity=entry.get('quantity'), + shipment=shipment, + ) + + class SOAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the SalesOrderAttachment model """ class Meta: - model = SalesOrderAttachment + model = order.models.SalesOrderAttachment fields = [ 'pk', diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index da78b83561..195f2273a3 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -119,6 +119,18 @@ src="{% static 'img/blank_image.png' %}" {{ order.supplier_reference }}{% include "clip.html"%} {% endif %} + + + {% trans "Completed Line Items" %} + + {{ order.completed_line_count }} / {{ order.line_count }} + {% if order.is_complete %} + {% trans "Complete" %} + {% else %} + {% trans "Incomplete" %} + {% endif %} + + {% if order.link %} diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 90a105caf1..d0215777bb 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -37,7 +37,7 @@
- {% include "filter_list.html" with id="order-lines" %} + {% include "filter_list.html" with id="purchase-order-lines" %}
@@ -190,6 +190,10 @@ $('#new-po-line').click(function() { $('#receive-selected-items').click(function() { var items = $("#po-line-table").bootstrapTable('getSelections'); + if (items.length == 0) { + items = $("#po-line-table").bootstrapTable('getData'); + } + receivePurchaseOrderItems( {{ order.id }}, items, diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 2a5a79a161..c8718d54d8 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -63,8 +63,8 @@ src="{% static 'img/blank_image.png' %}" {% if order.status == SalesOrderStatus.PENDING %} - {% endif %} {% endif %} @@ -123,6 +123,28 @@ src="{% static 'img/blank_image.png' %}" {% endif %} + + + + + + + + + + {% if order.link %} @@ -145,15 +167,13 @@ src="{% static 'img/blank_image.png' %}" {% if order.shipment_date %} - - - - {% endif %} - {% if order.status == PurchaseOrderStatus.COMPLETE %} - - - - + + {% endif %} {% if order.responsible %} @@ -203,8 +223,11 @@ $("#cancel-order").click(function() { }); }); -$("#ship-order").click(function() { - launchModalForm("{% url 'so-ship' order.id %}", { +$("#complete-order").click(function() { + constructForm('{% url "api-so-complete" order.id %}', { + method: 'POST', + title: '{% trans "Complete Sales Order" %}', + confirm: true, reload: true, }); }); diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 1cf2ce06cc..48b2542752 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -18,7 +18,7 @@

{% trans "Sales Order Items" %}

{% include "spacer.html" %}
- {% if roles.sales_order.change %} + {% if roles.sales_order.change and order.is_pending %} @@ -37,12 +37,67 @@
+{% if order.is_pending %} +
+
+
+

{% trans "Pending Shipments" %}

+ {% include "spacer.html" %} +
+ {% if 0 %} + + + {% endif %} + {% if roles.sales_order.change %} + + {% endif %} +
+
+
+
+ {% if roles.sales_order.change %} +
+
+ {% include "filter_list.html" with id="pending-shipments" %} +
+
+ {% endif %} +
{{ order.customer_reference }}{% include "clip.html"%}
{% trans "Completed Line Items" %} + {{ order.completed_line_count }} / {{ order.line_count }} + {% if order.is_completed %} + {% trans "Complete" %} + {% else %} + {% trans "Incomplete" %} + {% endif %} +
{% trans "Completed Shipments" %} + {{ order.completed_shipment_count }} / {{ order.shipment_count }} + {% if order.pending_shipment_count > 0 %} + {% trans "Incomplete" %} + {% endif %} +
{% trans "Shipped" %}{{ order.shipment_date }}{{ order.shipped_by }}
{% trans "Received" %}{{ order.complete_date }}{{ order.received_by }}{% trans "Completed" %} + {{ order.shipment_date }} + {% if order.shipped_by %} + {{ order.shipped_by }} + {% endif %} +
+
+ +{% endif %} + +
+
+

{% trans "Completed Shipments" %}

+
+
+
+
+ {% include "filter_list.html" with id="completed-shipments" %} +
+
+
+
+
+

{% trans "Build Orders" %}

-
+
+
+ {% include "filter_list.html" with id='build' %} +
+
+
@@ -89,6 +144,38 @@ {% block js_ready %} {{ block.super }} + // Callback when the "shipments" panel is first loaded + onPanelLoad('order-shipments', function() { + + {% if order.is_pending %} + loadSalesOrderShipmentTable('#pending-shipments-table', { + order: {{ order.pk }}, + shipped: false, + filter_target: '#filter-list-pending-shipments', + }); + + $('#new-shipment').click(function() { + createSalesOrderShipment({ + order: {{ order.pk }}, + reference: '{{ order.reference }}', + onSuccess: function(data) { + $('#pending-shipments-table').bootstrapTable('refresh'); + } + }); + }); + + {% endif %} + + }); + + onPanelLoad('order-shipments-complete', function() { + loadSalesOrderShipmentTable('#completed-shipments-table', { + order: {{ order.pk }}, + shipped: true, + filter_target: '#filter-list-completed-shipments', + }); + }); + $('#edit-notes').click(function() { constructForm('{% url "api-so-detail" order.pk %}', { fields: { diff --git a/InvenTree/order/templates/order/sales_order_ship.html b/InvenTree/order/templates/order/sales_order_ship.html deleted file mode 100644 index 763d3fca57..0000000000 --- a/InvenTree/order/templates/order/sales_order_ship.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "modal_form.html" %} - -{% load i18n %} - -{% block pre_form_content %} - -{% if not order.is_fully_allocated %} -
-

{% trans "Warning" %}

- {% trans "This order has not been fully allocated. If the order is marked as shipped, it can no longer be adjusted." %} -
- {% trans "Ensure that the order allocation is correct before shipping the order." %} -
-{% endif %} - -{% if order.is_over_allocated %} -
- {% trans "Some line items in this order have been over-allocated" %} -
- {% trans "Ensure that this is correct before shipping the order." %} -
-{% endif %} - -
- {% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }} -
- {% trans "Shipping this order means that the order will no longer be editable." %} -
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_allocate_by_serial.html b/InvenTree/order/templates/order/so_allocate_by_serial.html deleted file mode 100644 index 3e11d658c7..0000000000 --- a/InvenTree/order/templates/order/so_allocate_by_serial.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -
- {% include "hover_image.html" with image=part.image hover=true %}{{ part }} -
- {% trans "Allocate stock items by serial number" %} -
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_sidebar.html b/InvenTree/order/templates/order/so_sidebar.html index 5f5f040c28..c43e0537c5 100644 --- a/InvenTree/order/templates/order/so_sidebar.html +++ b/InvenTree/order/templates/order/so_sidebar.html @@ -4,6 +4,12 @@ {% trans "Line Items" as text %} {% include "sidebar_item.html" with label='order-items' text=text icon="fa-list-ol" %} +{% if order.is_pending %} +{% trans "Pending Shipments" as text %} +{% include "sidebar_item.html" with label='order-shipments' text=text icon="fa-truck-loading" %} +{% endif %} +{% trans "Completed Shipments" as text %} +{% include "sidebar_item.html" with label='order-shipments-complete' text=text icon="fa-truck" %} {% trans "Build Orders" as text %} {% include "sidebar_item.html" with label='order-builds' text=text icon="fa-tools" %} {% trans "Attachments" as text %} diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 44effe9fb6..2ea2086431 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -11,9 +11,10 @@ from django.urls import reverse from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.status_codes import PurchaseOrderStatus +from part.models import Part from stock.models import StockItem -from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder +import order.models as models class OrderTest(InvenTreeAPITestCase): @@ -85,7 +86,7 @@ class PurchaseOrderTest(OrderTest): self.filter({'overdue': True}, 0) self.filter({'overdue': False}, 7) - order = PurchaseOrder.objects.get(pk=1) + order = models.PurchaseOrder.objects.get(pk=1) order.target_date = datetime.now().date() - timedelta(days=10) order.save() @@ -137,7 +138,7 @@ class PurchaseOrderTest(OrderTest): Test that we can create / edit and delete a PurchaseOrder via the API """ - n = PurchaseOrder.objects.count() + n = models.PurchaseOrder.objects.count() url = reverse('api-po-list') @@ -154,7 +155,7 @@ class PurchaseOrderTest(OrderTest): ) # And no new PurchaseOrder objects should have been created - self.assertEqual(PurchaseOrder.objects.count(), n) + self.assertEqual(models.PurchaseOrder.objects.count(), n) # Ok, now let's give this user the correct permission self.assignRole('purchase_order.add') @@ -171,7 +172,7 @@ class PurchaseOrderTest(OrderTest): expected_code=201 ) - self.assertEqual(PurchaseOrder.objects.count(), n + 1) + self.assertEqual(models.PurchaseOrder.objects.count(), n + 1) pk = response.data['pk'] @@ -186,7 +187,7 @@ class PurchaseOrderTest(OrderTest): expected_code=400 ) - self.assertEqual(PurchaseOrder.objects.count(), n + 1) + self.assertEqual(models.PurchaseOrder.objects.count(), n + 1) url = reverse('api-po-detail', kwargs={'pk': pk}) @@ -217,7 +218,7 @@ class PurchaseOrderTest(OrderTest): response = self.delete(url, expected_code=204) # Number of PurchaseOrder objects should have decreased - self.assertEqual(PurchaseOrder.objects.count(), n) + self.assertEqual(models.PurchaseOrder.objects.count(), n) # And if we try to access the detail view again, it has gone response = self.get(url, expected_code=404) @@ -256,7 +257,7 @@ class PurchaseOrderReceiveTest(OrderTest): self.n = StockItem.objects.count() # Mark the order as "placed" so we can receive line items - order = PurchaseOrder.objects.get(pk=1) + order = models.PurchaseOrder.objects.get(pk=1) order.status = PurchaseOrderStatus.PLACED order.save() @@ -453,8 +454,8 @@ class PurchaseOrderReceiveTest(OrderTest): Test receipt of valid data """ - line_1 = PurchaseOrderLineItem.objects.get(pk=1) - line_2 = PurchaseOrderLineItem.objects.get(pk=2) + line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) + line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0) self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0) @@ -481,7 +482,7 @@ class PurchaseOrderReceiveTest(OrderTest): # Before posting "valid" data, we will mark the purchase order as "pending" # In this case we do expect an error! - order = PurchaseOrder.objects.get(pk=1) + order = models.PurchaseOrder.objects.get(pk=1) order.status = PurchaseOrderStatus.PENDING order.save() @@ -507,8 +508,8 @@ class PurchaseOrderReceiveTest(OrderTest): # There should be two newly created stock items self.assertEqual(self.n + 2, StockItem.objects.count()) - line_1 = PurchaseOrderLineItem.objects.get(pk=1) - line_2 = PurchaseOrderLineItem.objects.get(pk=2) + line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) + line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) self.assertEqual(line_1.received, 50) self.assertEqual(line_2.received, 250) @@ -563,7 +564,7 @@ class SalesOrderTest(OrderTest): self.filter({'overdue': False}, 5) for pk in [1, 2]: - order = SalesOrder.objects.get(pk=pk) + order = models.SalesOrder.objects.get(pk=pk) order.target_date = datetime.now().date() - timedelta(days=10) order.save() @@ -591,7 +592,7 @@ class SalesOrderTest(OrderTest): Test that we can create / edit and delete a SalesOrder via the API """ - n = SalesOrder.objects.count() + n = models.SalesOrder.objects.count() url = reverse('api-so-list') @@ -621,7 +622,7 @@ class SalesOrderTest(OrderTest): ) # Check that the new order has been created - self.assertEqual(SalesOrder.objects.count(), n + 1) + self.assertEqual(models.SalesOrder.objects.count(), n + 1) # Grab the PK for the newly created SalesOrder pk = response.data['pk'] @@ -664,7 +665,7 @@ class SalesOrderTest(OrderTest): response = self.delete(url, expected_code=204) # Check that the number of sales orders has decreased - self.assertEqual(SalesOrder.objects.count(), n) + self.assertEqual(models.SalesOrder.objects.count(), n) # And the resource should no longer be available response = self.get(url, expected_code=404) @@ -685,3 +686,131 @@ class SalesOrderTest(OrderTest): }, expected_code=201 ) + + +class SalesOrderAllocateTest(OrderTest): + """ + Unit tests for allocating stock items against a SalesOrder + """ + + def setUp(self): + super().setUp() + + self.assignRole('sales_order.add') + + self.url = reverse('api-so-allocate', kwargs={'pk': 1}) + + self.order = models.SalesOrder.objects.get(pk=1) + + # Create some line items for this purchase order + parts = Part.objects.filter(salable=True) + + for part in parts: + + # Create a new line item + models.SalesOrderLineItem.objects.create( + order=self.order, + part=part, + quantity=5, + ) + + # Ensure we have stock! + StockItem.objects.create( + part=part, + quantity=100, + ) + + # Create a new shipment against this SalesOrder + self.shipment = models.SalesOrderShipment.objects.create( + order=self.order, + ) + + def test_invalid(self): + """ + Test POST with invalid data + """ + + # No data + response = self.post(self.url, {}, expected_code=400) + + self.assertIn('This field is required', str(response.data['items'])) + self.assertIn('This field is required', str(response.data['shipment'])) + + # Test with a single line items + line = self.order.lines.first() + part = line.part + + # Valid stock_item, but quantity is invalid + data = { + 'items': [{ + "line_item": line.pk, + "stock_item": part.stock_items.last().pk, + "quantity": 0, + }], + } + + response = self.post(self.url, data, expected_code=400) + + self.assertIn('Quantity must be positive', str(response.data['items'])) + + # Valid stock item, too much quantity + data['items'][0]['quantity'] = 250 + + response = self.post(self.url, data, expected_code=400) + + self.assertIn('Available quantity (100) exceeded', str(response.data['items'])) + + # Valid stock item, valid quantity + data['items'][0]['quantity'] = 50 + + # Invalid shipment! + data['shipment'] = 9999 + + response = self.post(self.url, data, expected_code=400) + + self.assertIn('does not exist', str(response.data['shipment'])) + + # Valid shipment, but points to the wrong order + shipment = models.SalesOrderShipment.objects.create( + order=models.SalesOrder.objects.get(pk=2), + ) + + data['shipment'] = shipment.pk + + response = self.post(self.url, data, expected_code=400) + + self.assertIn('Shipment is not associated with this order', str(response.data['shipment'])) + + def test_allocate(self): + """ + Test the the allocation endpoint acts as expected, + when provided with valid data! + """ + + # First, check that there are no line items allocated against this SalesOrder + self.assertEqual(self.order.stock_allocations.count(), 0) + + data = { + "items": [], + "shipment": self.shipment.pk, + } + + for line in self.order.lines.all(): + stock_item = line.part.stock_items.last() + + # Fully-allocate each line + data['items'].append({ + "line_item": line.pk, + "stock_item": stock_item.pk, + "quantity": 5 + }) + + self.post(self.url, data, expected_code=201) + + # There should have been 1 stock item allocated against each line item + n_lines = self.order.lines.count() + + self.assertEqual(self.order.stock_allocations.count(), n_lines) + + for line in self.order.lines.all(): + self.assertEqual(line.allocations.count(), 1) diff --git a/InvenTree/order/test_migrations.py b/InvenTree/order/test_migrations.py index b7db1f1b70..3afba65223 100644 --- a/InvenTree/order/test_migrations.py +++ b/InvenTree/order/test_migrations.py @@ -4,16 +4,16 @@ Unit tests for the 'order' model data migrations from django_test_migrations.contrib.unittest_case import MigratorTestCase -from InvenTree import helpers +from InvenTree.status_codes import SalesOrderStatus -class TestForwardMigrations(MigratorTestCase): +class TestRefIntMigrations(MigratorTestCase): """ Test entire schema migration """ - migrate_from = ('order', helpers.getOldestMigrationFile('order')) - migrate_to = ('order', helpers.getNewestMigrationFile('order')) + migrate_from = ('order', '0040_salesorder_target_date') + migrate_to = ('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339') def prepare(self): """ @@ -26,10 +26,12 @@ class TestForwardMigrations(MigratorTestCase): supplier = Company.objects.create( name='Supplier A', description='A great supplier!', - is_supplier=True + is_supplier=True, + is_customer=True, ) PurchaseOrder = self.old_state.apps.get_model('order', 'purchaseorder') + SalesOrder = self.old_state.apps.get_model('order', 'salesorder') # Create some orders for ii in range(10): @@ -44,16 +46,79 @@ class TestForwardMigrations(MigratorTestCase): with self.assertRaises(AttributeError): print(order.reference_int) + sales_order = SalesOrder.objects.create( + customer=supplier, + reference=f"{ii}-xyz", + description="A test sales order", + ) + + # Initially, the 'reference_int' field is unavailable + with self.assertRaises(AttributeError): + print(sales_order.reference_int) + def test_ref_field(self): """ Test that the 'reference_int' field has been created and is filled out correctly """ PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder') + SalesOrder = self.new_state.apps.get_model('order', 'salesorder') for ii in range(10): - order = PurchaseOrder.objects.get(reference=f"{ii}-abcde") + po = PurchaseOrder.objects.get(reference=f"{ii}-abcde") + so = SalesOrder.objects.get(reference=f"{ii}-xyz") # The integer reference field must have been correctly updated - self.assertEqual(order.reference_int, ii) + self.assertEqual(po.reference_int, ii) + self.assertEqual(so.reference_int, ii) + + +class TestShipmentMigration(MigratorTestCase): + """ + Test data migration for the "SalesOrderShipment" model + """ + + migrate_from = ('order', '0051_auto_20211014_0623') + migrate_to = ('order', '0055_auto_20211025_0645') + + def prepare(self): + """ + Create an initial SalesOrder + """ + + Company = self.old_state.apps.get_model('company', 'company') + + customer = Company.objects.create( + name='My customer', + description='A customer we sell stuff too', + is_customer=True + ) + + SalesOrder = self.old_state.apps.get_model('order', 'salesorder') + + for ii in range(5): + order = SalesOrder.objects.create( + reference=f'SO{ii}', + customer=customer, + description='A sales order for stuffs', + status=SalesOrderStatus.PENDING, + ) + + order.save() + + # The "shipment" model does not exist yet + with self.assertRaises(LookupError): + self.old_state.apps.get_model('order', 'salesordershipment') + + def test_shipment_creation(self): + """ + Check that a SalesOrderShipment has been created + """ + + SalesOrder = self.new_state.apps.get_model('order', 'salesorder') + Shipment = self.new_state.apps.get_model('order', 'salesordershipment') + + # Check that the correct number of Shipments have been created + self.assertEqual(SalesOrder.objects.count(), 5) + self.assertEqual(Shipment.objects.count(), 5) diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index 8337ff4b57..d43c94996c 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -7,11 +7,15 @@ from django.core.exceptions import ValidationError from datetime import datetime, timedelta from company.models import Company -from stock.models import StockItem -from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation -from part.models import Part + from InvenTree import status_codes as status +from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation, SalesOrderShipment + +from part.models import Part + +from stock.models import StockItem + class SalesOrderTest(TestCase): """ @@ -38,6 +42,12 @@ class SalesOrderTest(TestCase): customer_reference='ABC 55555' ) + # Create a Shipment against this SalesOrder + self.shipment = SalesOrderShipment.objects.create( + order=self.order, + reference='001', + ) + # Create a line item self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part) @@ -82,11 +92,13 @@ class SalesOrderTest(TestCase): # Allocate stock to the order SalesOrderAllocation.objects.create( line=self.line, + shipment=self.shipment, item=StockItem.objects.get(pk=self.Sa.pk), quantity=25) SalesOrderAllocation.objects.create( line=self.line, + shipment=self.shipment, item=StockItem.objects.get(pk=self.Sb.pk), quantity=25 if full else 20 ) @@ -120,11 +132,14 @@ class SalesOrderTest(TestCase): self.assertEqual(SalesOrderAllocation.objects.count(), 0) self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED) - # Now try to ship it - should fail with self.assertRaises(ValidationError): - self.order.ship_order(None) + self.order.can_complete(raise_error=True) - def test_ship_order(self): + # Now try to ship it - should fail + result = self.order.complete_order(None) + self.assertFalse(result) + + def test_complete_order(self): # Allocate line items, then ship the order # Assert some stuff before we run the test @@ -136,7 +151,25 @@ class SalesOrderTest(TestCase): self.assertEqual(SalesOrderAllocation.objects.count(), 2) - self.order.ship_order(None) + # Attempt to complete the order (but shipments are not completed!) + result = self.order.complete_order(None) + + self.assertFalse(result) + + self.assertIsNone(self.shipment.shipment_date) + self.assertFalse(self.shipment.is_complete()) + + # Mark the shipments as complete + self.shipment.complete_shipment(None) + self.assertTrue(self.shipment.is_complete()) + + # Now, should be OK to ship + result = self.order.complete_order(None) + + self.assertTrue(result) + + self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED) + self.assertIsNotNone(self.order.shipment_date) # There should now be 4 stock items self.assertEqual(StockItem.objects.count(), 4) @@ -158,12 +191,12 @@ class SalesOrderTest(TestCase): self.assertEqual(sa.sales_order, None) self.assertEqual(sb.sales_order, None) - # And no allocations - self.assertEqual(SalesOrderAllocation.objects.count(), 0) + # And the allocations still exist + self.assertEqual(SalesOrderAllocation.objects.count(), 2) self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED) self.assertTrue(self.order.is_fully_allocated()) self.assertTrue(self.line.is_fully_allocated()) self.assertEqual(self.line.fulfilled_quantity(), 50) - self.assertEqual(self.line.allocated_quantity(), 0) + self.assertEqual(self.line.allocated_quantity(), 50) diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index afc689cc23..504145892a 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -35,18 +35,12 @@ purchase_order_urls = [ sales_order_detail_urls = [ url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'), - url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'), url(r'^export/', views.SalesOrderExport.as_view(), name='so-export'), url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'), ] sales_order_urls = [ - # URLs for sales order allocations - url(r'^allocation/', include([ - url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'), - ])), - # Display detail view for a single SalesOrder url(r'^(?P\d+)/', include(sales_order_detail_urls)), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 8aea60f694..c89d2a77b1 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -9,12 +9,10 @@ from django.db import transaction from django.db.utils import IntegrityError from django.http.response import JsonResponse from django.shortcuts import get_object_or_404 -from django.core.exceptions import ValidationError from django.urls import reverse from django.http import HttpResponseRedirect from django.utils.translation import ugettext_lazy as _ from django.views.generic import DetailView, ListView -from django.views.generic.edit import FormMixin from django.forms import HiddenInput, IntegerField import logging @@ -22,7 +20,6 @@ from decimal import Decimal, InvalidOperation from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem -from .models import SalesOrderAllocation from .admin import POLineItemResource, SOLineItemResource from build.models import Build from company.models import Company, SupplierPart # ManufacturerPart @@ -38,7 +35,6 @@ from part.views import PartPricing from InvenTree.views import AjaxView, AjaxUpdateView from InvenTree.helpers import DownloadFile, str2bool -from InvenTree.helpers import extract_serial_numbers from InvenTree.views import InvenTreeRoleMixin from InvenTree.status_codes import PurchaseOrderStatus @@ -213,48 +209,6 @@ class PurchaseOrderComplete(AjaxUpdateView): } -class SalesOrderShip(AjaxUpdateView): - """ View for 'shipping' a SalesOrder """ - form_class = order_forms.ShipSalesOrderForm - model = SalesOrder - context_object_name = 'order' - ajax_template_name = 'order/sales_order_ship.html' - ajax_form_title = _('Ship Order') - - def post(self, request, *args, **kwargs): - - self.request = request - - order = self.get_object() - self.object = order - - form = self.get_form() - - confirm = str2bool(request.POST.get('confirm', False)) - - valid = False - - if not confirm: - form.add_error('confirm', _('Confirm order shipment')) - else: - valid = True - - if valid: - if not order.ship_order(request.user): - form.add_error(None, _('Could not ship order')) - valid = False - - data = { - 'form_valid': valid, - } - - context = self.get_context_data() - - context['order'] = order - - return self.renderJsonResponse(request, form, data, context) - - class PurchaseOrderUpload(FileManagementFormView): ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) ''' @@ -834,174 +788,6 @@ class OrderParts(AjaxView): order.add_line_item(supplier_part, quantity, purchase_price=purchase_price) -class SalesOrderAssignSerials(AjaxView, FormMixin): - """ - View for assigning stock items to a sales order, - by serial number lookup. - """ - - model = SalesOrderAllocation - role_required = 'sales_order.change' - ajax_template_name = 'order/so_allocate_by_serial.html' - ajax_form_title = _('Allocate Serial Numbers') - form_class = order_forms.AllocateSerialsToSalesOrderForm - - # Keep track of SalesOrderLineItem and Part references - line = None - part = None - - def get_initial(self): - """ - Initial values are passed as query params - """ - - initials = super().get_initial() - - try: - self.line = SalesOrderLineItem.objects.get(pk=self.request.GET.get('line', None)) - initials['line'] = self.line - except (ValueError, SalesOrderLineItem.DoesNotExist): - pass - - try: - self.part = Part.objects.get(pk=self.request.GET.get('part', None)) - initials['part'] = self.part - except (ValueError, Part.DoesNotExist): - pass - - return initials - - def post(self, request, *args, **kwargs): - - self.form = self.get_form() - - # Validate the form - self.form.is_valid() - self.validate() - - valid = self.form.is_valid() - - if valid: - self.allocate_items() - - data = { - 'form_valid': valid, - 'form_errors': self.form.errors.as_json(), - 'non_field_errors': self.form.non_field_errors().as_json(), - 'success': _("Allocated {n} items").format(n=len(self.stock_items)) - } - - return self.renderJsonResponse(request, self.form, data) - - def validate(self): - - data = self.form.cleaned_data - - # Extract hidden fields from posted data - self.line = data.get('line', None) - self.part = data.get('part', None) - - if self.line: - self.form.fields['line'].widget = HiddenInput() - else: - self.form.add_error('line', _('Select line item')) - - if self.part: - self.form.fields['part'].widget = HiddenInput() - else: - self.form.add_error('part', _('Select part')) - - if not self.form.is_valid(): - return - - # Form is otherwise valid - check serial numbers - serials = data.get('serials', '') - quantity = data.get('quantity', 1) - - # Save a list of serial_numbers - self.serial_numbers = None - self.stock_items = [] - - try: - self.serial_numbers = extract_serial_numbers(serials, quantity) - - for serial in self.serial_numbers: - try: - # Find matching stock item - stock_item = StockItem.objects.get( - part=self.part, - serial=serial - ) - except StockItem.DoesNotExist: - self.form.add_error( - 'serials', - _('No matching item for serial {serial}').format(serial=serial) - ) - continue - - # Now we have a valid stock item - but can it be added to the sales order? - - # If not in stock, cannot be added to the order - if not stock_item.in_stock: - self.form.add_error( - 'serials', - _('{serial} is not in stock').format(serial=serial) - ) - continue - - # Already allocated to an order - if stock_item.is_allocated(): - self.form.add_error( - 'serials', - _('{serial} already allocated to an order').format(serial=serial) - ) - continue - - # Add it to the list! - self.stock_items.append(stock_item) - - except ValidationError as e: - self.form.add_error('serials', e.messages) - - def allocate_items(self): - """ - Create stock item allocations for each selected serial number - """ - - for stock_item in self.stock_items: - SalesOrderAllocation.objects.create( - item=stock_item, - line=self.line, - quantity=1, - ) - - def get_form(self): - - form = super().get_form() - - if self.line: - form.fields['line'].widget = HiddenInput() - - if self.part: - form.fields['part'].widget = HiddenInput() - - return form - - def get_context_data(self): - return { - 'line': self.line, - 'part': self.part, - } - - def get(self, request, *args, **kwargs): - - return self.renderJsonResponse( - request, - self.get_form(), - context=self.get_context_data(), - ) - - class LineItemPricing(PartPricing): """ View for inspecting part pricing information """ diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 3c24690efc..77e808fd7f 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -69,6 +69,7 @@ name: 'Widget' description: 'A watchamacallit' category: 7 + salable: true assembly: true trackable: true tree_id: 0 @@ -83,6 +84,7 @@ name: 'Orphan' description: 'A part without a category' category: null + salable: true tree_id: 0 level: 0 lft: 0 @@ -95,6 +97,7 @@ name: 'Bob' description: 'Can we build it?' assembly: true + salable: true purchaseable: false category: 7 active: False @@ -113,6 +116,7 @@ description: 'A chair' is_template: True trackable: true + salable: true category: 7 tree_id: 1 level: 0 diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 8385041209..760a18c72c 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta from django.core.exceptions import ValidationError as DjangoValidationError from django.conf.urls import url, include from django.http import JsonResponse -from django.db.models import Q +from django.db.models import Q, F from django.db import transaction from django.utils.translation import ugettext_lazy as _ @@ -304,6 +304,24 @@ class StockFilter(rest_filters.FilterSet): return queryset + available = rest_filters.BooleanFilter(label='Available', method='filter_available') + + def filter_available(self, queryset, name, value): + """ + Filter by whether the StockItem is "available" or not. + + Here, "available" means that the allocated quantity is less than the total quantity + """ + + if str2bool(value): + # The 'quantity' field is greater than the calculated 'allocated' field + queryset = queryset.filter(Q(quantity__gt=F('allocated'))) + else: + # The 'quantity' field is less than (or equal to) the calculated 'allocated' field + queryset = queryset.filter(Q(quantity__lte=F('allocated'))) + + return queryset + batch = rest_filters.CharFilter(label="Batch code filter (case insensitive)", lookup_expr='iexact') batch_regex = rest_filters.CharFilter(label="Batch code filter (regex)", field_name='batch', lookup_expr='iregex') diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 0d5ab272d6..949c172e9e 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -252,7 +252,7 @@
{% object_link 'so-detail' allocation.line.order.id allocation.line.order as link %} {% decimal allocation.quantity as qty %} - {% blocktrans %}This stock item is allocated to Sales Order {{ link }} (Quantity: {{ qty }}){% endblocktrans %} + {% trans "This stock item is allocated to Sales Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
{% endfor %} @@ -260,7 +260,7 @@
{% object_link 'build-detail' allocation.build.id allocation.build %} {% decimal allocation.quantity as qty %} - {% blocktrans %}This stock item is allocated to Build {{ link }} (Quantity: {{ qty }}){% endblocktrans %} + {% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
{% endfor %} diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index bcbbfcc6d1..02b2ff5321 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1226,7 +1226,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { * * options: * - output: ID / PK of the associated build output (or null for untracked items) - * - source_location: ID / PK of the top-level StockLocation to take parts from (or null) + * - source_location: ID / PK of the top-level StockLocation to source stock from (or null) */ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { @@ -1339,7 +1339,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { var html = ``; - // Render a "take from" input + // Render a "source location" input html += constructField( 'take_from', { @@ -1397,6 +1397,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { options, ); + // Add callback to "clear" button for take_from field + addClearCallback( + 'take_from', + take_from_field, + options, + ); + // Initialize stock item fields bom_items.forEach(function(bom_item) { initializeRelatedField( @@ -1406,6 +1413,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { filters: { bom_item: bom_item.pk, in_stock: true, + available: true, part_detail: true, location_detail: true, }, @@ -1456,14 +1464,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { ); }); - // Add callback to "clear" button for take_from field - addClearCallback( - 'take_from', - take_from_field, - options, - ); - - // Add button callbacks + // Add remove-row button callbacks $(options.modal).find('.button-row-remove').click(function() { var pk = $(this).attr('pk'); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 5af85d382e..def7e41358 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -730,6 +730,9 @@ function submitFormData(fields, options) { data = options.processBeforeUpload(data); } + // Show the progress spinner + $(options.modal).find('#modal-progress-spinner').show(); + // Submit data upload_func( options.url, @@ -737,10 +740,13 @@ function submitFormData(fields, options) { { method: options.method, success: function(response) { + $(options.modal).find('#modal-progress-spinner').hide(); handleFormSuccess(response, options); }, error: function(xhr) { + $(options.modal).find('#modal-progress-spinner').hide(); + switch (xhr.status) { case 400: handleFormErrors(xhr.responseJSON, fields, options); @@ -1713,6 +1719,9 @@ function renderModelData(name, model, data, parameters, options) { case 'salesorder': renderer = renderSalesOrder; break; + case 'salesordershipment': + renderer = renderSalesOrderShipment; + break; case 'manufacturerpart': renderer = renderManufacturerPart; break; diff --git a/InvenTree/templates/js/translated/modals.js b/InvenTree/templates/js/translated/modals.js index 4cd0be8cec..c01bb6c34a 100644 --- a/InvenTree/templates/js/translated/modals.js +++ b/InvenTree/templates/js/translated/modals.js @@ -72,6 +72,7 @@ function createNewModal(options={}) { +

diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 38ea7b70f0..710f67ad67 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -241,6 +241,23 @@ function renderSalesOrder(name, data, parameters, options) { } +// Renderer for "SalesOrderShipment" model +// eslint-disable-next-line no-unused-vars +function renderSalesOrderShipment(name, data, parameters, options) { + + var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX; + + var html = ` + ${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference} + + {% trans "Shipment ID" %}: ${data.pk} + + `; + + return html; +} + + // Renderer for "PartCategory" model // eslint-disable-next-line no-unused-vars function renderPartCategory(name, data, parameters, options) { diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index d565a6f1c3..fefa82475c 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -19,13 +19,17 @@ */ /* exported + allocateStockToSalesOrder, + completeShipment, createSalesOrder, + createSalesOrderShipment, editPurchaseOrderLineItem, exportOrder, loadPurchaseOrderLineItemTable, loadPurchaseOrderTable, loadSalesOrderAllocationTable, loadSalesOrderLineItemTable, + loadSalesOrderShipmentTable, loadSalesOrderTable, newPurchaseOrderFromOrderWizard, newSupplierPartFromOrderWizard, @@ -33,6 +37,160 @@ removePurchaseOrderLineItem, */ + +function salesOrderShipmentFields(options={}) { + var fields = { + order: {}, + reference: {}, + tracking_number: { + icon: 'fa-hashtag', + }, + }; + + // If order is specified, hide the order field + if (options.order) { + fields.order.value = options.order; + fields.order.hidden = true; + } + + return fields; +} + + +/* + * Complete a shipment + */ +function completeShipment(shipment_id) { + + // Request the list of stock items which will be shipped + inventreeGet(`/api/order/so/shipment/${shipment_id}/`, {}, { + success: function(shipment) { + var allocations = shipment.allocations; + + var html = ''; + + if (!allocations || allocations.length == 0) { + html = ` +
+ {% trans "No stock items have been allocated to this shipment" %} +
+ `; + } else { + html = ` + {% trans "The following stock items will be shipped" %} + + + + + + + + + `; + + allocations.forEach(function(allocation) { + + var part = allocation.part_detail; + var thumb = thumbnailImage(part.thumbnail || part.image); + + var stock = ''; + + if (allocation.serial) { + stock = `{% trans "Serial Number" %}: ${allocation.serial}`; + } else { + stock = `{% trans "Quantity" %}: ${allocation.quantity}`; + } + + html += ` + + + + + `; + }); + + html += ` + +
{% trans "Part" %}{% trans "Stock Item" %}
${thumb} ${part.full_name}${stock}
+ `; + } + + constructForm(`/api/order/so/shipment/${shipment_id}/ship/`, { + method: 'POST', + title: '{% trans "Complete Shipment" %}', + fields: { + tracking_number: {}, + }, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm Shipment" %}', + onSuccess: function(data) { + // Reload tables + $('#so-lines-table').bootstrapTable('refresh'); + $('#pending-shipments-table').bootstrapTable('refresh'); + $('#completed-shipments-table').bootstrapTable('refresh'); + } + }); + } + }); +} + + +// Open a dialog to create a new sales order shipment +function createSalesOrderShipment(options={}) { + + // Work out the next shipment number for the given order + inventreeGet( + '{% url "api-so-shipment-list" %}', + { + order: options.order, + }, + { + success: function(results) { + // "predict" the next reference number + var ref = results.length + 1; + + var found = false; + + while (!found) { + + var no_match = true; + + for (var ii = 0; ii < results.length; ii++) { + if (ref.toString() == results[ii].reference.toString()) { + no_match = false; + break; + } + } + + if (no_match) { + break; + } else { + ref++; + } + } + + var fields = salesOrderShipmentFields(options); + + fields.reference.value = ref; + fields.reference.prefix = global_settings.SALESORDER_REFERENCE_PREFIX + options.reference; + + constructForm('{% url "api-so-shipment-list" %}', { + method: 'POST', + fields: fields, + title: '{% trans "Create New Shipment" %}', + onSuccess: function(data) { + if (options.onSuccess) { + options.onSuccess(data); + } + } + }); + } + } + ); +} + + // Create a new SalesOrder function createSalesOrder(options={}) { @@ -295,7 +453,9 @@ function newPurchaseOrderFromOrderWizard(e) { */ function receivePurchaseOrderItems(order_id, line_items, options={}) { + // Zero items selected? if (line_items.length == 0) { + showAlertDialog( '{% trans "Select Line Items" %}', '{% trans "At least one line item must be selected" %}', @@ -601,6 +761,9 @@ function removePurchaseOrderLineItem(e) { } +/* + * Load a table displaying list of purchase orders + */ function loadPurchaseOrderTable(table, options) { /* Create a purchase-order table */ @@ -648,13 +811,6 @@ function loadPurchaseOrderTable(table, options) { var html = renderLink(value, `/order/purchase-order/${row.pk}/`); - html += purchaseOrderStatusDisplay( - row.status, - { - classes: 'float-right', - } - ); - if (row.overdue) { html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}'); } @@ -679,6 +835,15 @@ function loadPurchaseOrderTable(table, options) { field: 'description', title: '{% trans "Description" %}', }, + { + field: 'status', + title: '{% trans "Status" %}', + switchable: true, + sortable: true, + formatter: function(value, row) { + return purchaseOrderStatusDisplay(row.status); + } + }, { field: 'creation_date', title: '{% trans "Date" %}', @@ -1012,6 +1177,9 @@ function loadPurchaseOrderLineItemTable(table, options={}) { } +/* + * Load table displaying list of sales orders + */ function loadSalesOrderTable(table, options) { options.params = options.params || {}; @@ -1094,7 +1262,7 @@ function loadSalesOrderTable(table, options) { field: 'status', title: '{% trans "Status" %}', formatter: function(value, row) { - return salesOrderStatusDisplay(row.status, row.status_text); + return salesOrderStatusDisplay(row.status); } }, { @@ -1122,6 +1290,493 @@ function loadSalesOrderTable(table, options) { } +/* + * Load a table displaying Shipment information against a particular order + */ +function loadSalesOrderShipmentTable(table, options={}) { + + options.table = table; + + options.params = options.params || {}; + + // Filter by order + options.params.order = options.order; + + // Filter by "shipped" status + options.params.shipped = options.shipped || false; + + var filters = loadTableFilters('salesordershipment'); + + for (var key in options.params) { + filters[key] = options.params[key]; + } + + setupFilterList('salesordershipment', $(table), options.filter_target); + + function makeShipmentActions(row) { + // Construct "actions" for the given shipment row + var pk = row.pk; + + var html = `
`; + + html += makeIconButton('fa-edit icon-blue', 'button-shipment-edit', pk, '{% trans "Edit shipment" %}'); + + if (!options.shipped) { + html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}'); + } + + var enable_delete = row.allocations && row.allocations.length == 0; + + html += makeIconButton('fa-trash-alt icon-red', 'button-shipment-delete', pk, '{% trans "Delete shipment" %}', {disabled: !enable_delete}); + + html += `
`; + + return html; + + } + + function setupShipmentCallbacks() { + // Setup action button callbacks + + $(table).find('.button-shipment-edit').click(function() { + var pk = $(this).attr('pk'); + + var fields = salesOrderShipmentFields(); + + delete fields.order; + + constructForm(`/api/order/so/shipment/${pk}/`, { + fields: fields, + title: '{% trans "Edit Shipment" %}', + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + }); + }); + + $(table).find('.button-shipment-ship').click(function() { + var pk = $(this).attr('pk'); + + completeShipment(pk); + }); + + $(table).find('.button-shipment-delete').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`/api/order/so/shipment/${pk}/`, { + title: '{% trans "Delete Shipment" %}', + method: 'DELETE', + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + }); + }); + } + + $(table).inventreeTable({ + url: '{% url "api-so-shipment-list" %}', + queryParams: filters, + original: options.params, + name: options.name || 'salesordershipment', + search: false, + paginationVAlign: 'bottom', + showColumns: true, + detailView: true, + detailViewByClick: false, + detailFilter: function(index, row) { + return row.allocations.length > 0; + }, + detailFormatter: function(index, row, element) { + return showAllocationSubTable(index, row, element, options); + }, + onPostBody: function() { + setupShipmentCallbacks(); + + // Auto-expand rows on the "pending" table + if (!options.shipped) { + $(table).bootstrapTable('expandAllRows'); + } + }, + formatNoMatches: function() { + return '{% trans "No matching shipments found" %}'; + }, + columns: [ + { + visible: false, + checkbox: true, + switchable: false, + }, + { + field: 'reference', + title: '{% trans "Shipment Reference" %}', + switchable: false, + }, + { + field: 'allocations', + title: '{% trans "Items" %}', + switchable: false, + sortable: true, + formatter: function(value, row) { + if (row && row.allocations) { + return row.allocations.length; + } else { + return '-'; + } + } + }, + { + field: 'shipment_date', + title: '{% trans "Shipment Date" %}', + sortable: true, + formatter: function(value, row) { + if (value) { + return value; + } else { + return '{% trans "Not shipped" %}'; + } + } + }, + { + field: 'tracking_number', + title: '{% trans "Tracking" %}', + }, + { + field: 'notes', + title: '{% trans "Notes" %}', + visible: false, + // TODO: Implement 'notes' field + }, + { + title: '', + switchable: false, + formatter: function(value, row) { + return makeShipmentActions(row); + } + } + ], + }); +} + + +/** + * Allocate stock items against a SalesOrder + * + * arguments: + * - order_id: The ID / PK value for the SalesOrder + * - lines: A list of SalesOrderLineItem objects to be allocated + * + * options: + * - source_location: ID / PK of the top-level StockLocation to source stock from (or null) + */ +function allocateStockToSalesOrder(order_id, line_items, options={}) { + + function renderLineItemRow(line_item, quantity) { + // Function to render a single line_item row + + var pk = line_item.pk; + + var part = line_item.part_detail; + + var thumb = thumbnailImage(part.thumbnail || part.image); + + var delete_button = `
`; + + delete_button += makeIconButton( + 'fa-times icon-red', + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ); + + delete_button += '
'; + + var quantity_input = constructField( + `items_quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: quantity || 0, + title: '{% trans "Specify stock allocation quantity" %}', + required: true, + }, + { + hideLabels: true, + } + ); + + var stock_input = constructField( + `items_stock_item_${pk}`, + { + type: 'related field', + required: 'true', + }, + { + hideLabels: true, + } + ); + + var html = ` + + + ${thumb} ${part.full_name} + + + ${stock_input} + + + ${quantity_input} + + + + + {% trans "Part" %} + {% trans "Stock Item" %} + {% trans "Quantity" %} + + + + ${table_entries} + + `; + + constructForm(`/api/order/so/${order_id}/allocate/`, { + method: 'POST', + fields: { + shipment: { + filters: { + order: order_id, + shipped: false, + }, + value: options.shipment || null, + auto_fill: true, + } + }, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm stock allocation" %}', + title: '{% trans "Allocate Stock Items to Sales Order" %}', + afterRender: function(fields, opts) { + + // Initialize source location field + var take_from_field = { + name: 'take_from', + model: 'stocklocation', + api_url: '{% url "api-location-list" %}', + required: false, + type: 'related field', + value: options.source_location || null, + noResults: function(query) { + return '{% trans "No matching stock locations" %}'; + }, + }; + + initializeRelatedField( + take_from_field, + null, + opts + ); + + // Add callback to "clear" button for take_from field + addClearCallback( + 'take_from', + take_from_field, + opts, + ); + + // Initialize fields for each line item + line_items.forEach(function(line_item) { + var pk = line_item.pk; + + initializeRelatedField( + { + name: `items_stock_item_${pk}`, + api_url: '{% url "api-stock-list" %}', + filters: { + part: line_item.part, + in_stock: true, + part_detail: true, + location_detail: true, + available: true, + }, + model: 'stockitem', + required: true, + render_part_detail: true, + render_location_detail: true, + auto_fill: true, + onSelect: function(data, field, opts) { + // Adjust the 'quantity' field based on availability + + if (!('quantity' in data)) { + return; + } + + // Calculate the available quantity + var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); + + // Remaining quantity to be allocated? + var remaining = opts.quantity || available; + + // Maximum amount that we need + var desired = Math.min(available, remaining); + + updateFieldValue(`items_quantity_${pk}`, desired, {}, opts); + + }, + adjustFilters: function(filters) { + // Restrict query to the selected location + var location = getFormFieldValue( + 'take_from', + {}, + { + modal: opts.modal, + } + ); + + filters.location = location; + filters.cascade = true; + + // Exclude expired stock? + if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) { + filters.expired = false; + } + + return filters; + }, + noResults: function(query) { + return '{% trans "No matching stock items" %}'; + } + }, + null, + opts + ); + }); + + // Add remove-row button callbacks + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#allocation_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + // Extract data elements from the form + var data = { + items: [], + shipment: getFormFieldValue( + 'shipment', + {}, + opts + ) + }; + + var item_pk_values = []; + + line_items.forEach(function(item) { + + var pk = item.pk; + + var quantity = getFormFieldValue( + `items_quantity_${pk}`, + {}, + opts + ); + + var stock_item = getFormFieldValue( + `items_stock_item_${pk}`, + {}, + opts + ); + + if (quantity != null) { + data.items.push({ + line_item: pk, + stock_item: stock_item, + quantity: quantity, + }); + + item_pk_values.push(pk); + } + }); + + // Provide nested values + opts.nested = { + 'items': item_pk_values + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr); + break; + } + } + } + ); + }, + }); +} + + function loadSalesOrderAllocationTable(table, options={}) { /** * Load a table with SalesOrderAllocation items @@ -1145,7 +1800,7 @@ function loadSalesOrderAllocationTable(table, options={}) { $(table).inventreeTable({ url: '{% url "api-so-allocation-list" %}', queryParams: filters, - name: 'salesorderallocation', + name: options.name || 'salesorderallocation', groupBy: false, search: false, paginationVAlign: 'bottom', @@ -1203,7 +1858,7 @@ function loadSalesOrderAllocationTable(table, options={}) { field: 'quantity', title: '{% trans "Quantity" %}', sortable: true, - } + }, ] }); } @@ -1220,17 +1875,13 @@ function showAllocationSubTable(index, row, element, options) { // Construct a sub-table element var html = `
- -
+
`; element.html(html); var table = $(`#allocation-table-${row.pk}`); - // Is the parent SalesOrder pending? - var pending = options.status == {{ SalesOrderStatus.PENDING }}; - function setupCallbacks() { // Add callbacks for 'edit' buttons table.find('.button-allocation-edit').click(function() { @@ -1275,11 +1926,18 @@ function showAllocationSubTable(index, row, element, options) { table.bootstrapTable({ onPostBody: setupCallbacks, data: row.allocations, - showHeader: false, + showHeader: true, columns: [ + { + field: 'part_detail', + title: '{% trans "Part" %}', + formatter: function(part, row) { + return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`); + } + }, { field: 'allocated', - title: '{% trans "Quantity" %}', + title: '{% trans "Stock Item" %}', formatter: function(value, row, index, field) { var text = ''; @@ -1297,32 +1955,30 @@ function showAllocationSubTable(index, row, element, options) { title: '{% trans "Location" %}', formatter: function(value, row, index, field) { - // Location specified - if (row.location) { + if (row.shipment_date) { + return `{% trans "Shipped to customer" %} - ${row.shipment_date}`; + } else if (row.location) { + // Location specified return renderLink( row.location_detail.pathstring || '{% trans "Location" %}', `/stock/location/${row.location}/` ); } else { - return `{% trans "Stock location not specified" %}`; + return `{% trans "Stock location not specified" %}`; } }, }, - // TODO: ?? What is 'po' field all about? - /* - { - field: 'po' - }, - */ { field: 'buttons', - title: '{% trans "Actions" %}', + title: '{% trans "" %}', formatter: function(value, row, index, field) { var html = `
`; var pk = row.pk; - if (pending) { + if (row.shipment_date) { + html += `{% trans "Shipped" %}`; + } else { html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); } @@ -1361,8 +2017,9 @@ function showFulfilledSubTable(index, row, element, options) { queryParams: { part: row.part, sales_order: options.order, + location_detail: true, }, - showHeader: false, + showHeader: true, columns: [ { field: 'pk', @@ -1370,6 +2027,7 @@ function showFulfilledSubTable(index, row, element, options) { }, { field: 'stock', + title: '{% trans "Stock Item" %}', formatter: function(value, row) { var text = ''; if (row.serial && row.quantity == 1) { @@ -1381,11 +2039,25 @@ function showFulfilledSubTable(index, row, element, options) { return renderLink(text, `/stock/item/${row.pk}/`); }, }, - /* { - field: 'po' - }, - */ + field: 'location', + title: '{% trans "Location" %}', + formatter: function(value, row) { + if (row.customer) { + return renderLink( + '{% trans "Shipped to customer" %}', + `/company/${row.customer}/` + ); + } else if (row.location && row.location_detail) { + return renderLink( + row.location_detail.pathstring, + `/stock/location/${row.location}`, + ); + } else { + return `{% trans "Stock location not specified" %}`; + } + } + } ], }); } @@ -1429,7 +2101,7 @@ function loadSalesOrderLineItemTable(table, options={}) { var filter_target = options.filter_target || '#filter-list-sales-order-lines'; - setupFilterList('salesorderlineitems', $(table), filter_target); + setupFilterList('salesorderlineitem', $(table), filter_target); // Is the order pending? var pending = options.status == {{ SalesOrderStatus.PENDING }}; @@ -1451,7 +2123,7 @@ function loadSalesOrderLineItemTable(table, options={}) { */ { sortable: true, - sortName: 'part__name', + sortName: 'part_detail.name', field: 'part', title: '{% trans "Part" %}', switchable: false, @@ -1548,40 +2220,65 @@ function loadSalesOrderLineItemTable(table, options={}) { }, }, ); + + columns.push( + { + field: 'allocated', + title: '{% trans "Allocated" %}', + switchable: false, + sortable: true, + formatter: function(value, row, index, field) { + return makeProgressBar(row.allocated, row.quantity, { + id: `order-line-progress-${row.pk}`, + }); + }, + sorter: function(valA, valB, rowA, rowB) { + + var A = rowA.allocated; + var B = rowB.allocated; + + if (A == 0 && B == 0) { + return (rowA.quantity > rowB.quantity) ? 1 : -1; + } + + var progressA = parseFloat(A) / rowA.quantity; + var progressB = parseFloat(B) / rowB.quantity; + + return (progressA < progressB) ? 1 : -1; + } + }, + ); } - columns.push( - { - field: 'allocated', - title: pending ? '{% trans "Allocated" %}' : '{% trans "Fulfilled" %}', - switchable: false, - formatter: function(value, row, index, field) { + columns.push({ + field: 'shipped', + title: '{% trans "Shipped" %}', + switchable: false, + sortable: true, + formatter: function(value, row) { + return makeProgressBar(row.shipped, row.quantity, { + id: `order-line-shipped-${row.pk}` + }); + }, + sorter: function(valA, valB, rowA, rowB) { + var A = rowA.shipped; + var B = rowB.shipped; - var quantity = pending ? row.allocated : row.fulfilled; - return makeProgressBar(quantity, row.quantity, { - id: `order-line-progress-${row.pk}`, - }); - }, - sorter: function(valA, valB, rowA, rowB) { - - var A = pending ? rowA.allocated : rowA.fulfilled; - var B = pending ? rowB.allocated : rowB.fulfilled; - - if (A == 0 && B == 0) { - return (rowA.quantity > rowB.quantity) ? 1 : -1; - } - - var progressA = parseFloat(A) / rowA.quantity; - var progressB = parseFloat(B) / rowB.quantity; - - return (progressA < progressB) ? 1 : -1; + if (A == 0 && B == 0) { + return (rowA.quantity > rowB.quantity) ? 1 : -1; } - }, - { - field: 'notes', - title: '{% trans "Notes" %}', - }, - ); + + var progressA = parseFloat(A) / rowA.quantity; + var progressB = parseFloat(B) / rowB.quantity; + + return (progressA < progressB) ? 1 : -1; + } + }); + + columns.push({ + field: 'notes', + title: '{% trans "Notes" %}', + }); if (pending) { columns.push({ @@ -1614,7 +2311,21 @@ function loadSalesOrderLineItemTable(table, options={}) { } html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}'); + + var delete_disabled = false; + + var title = '{% trans "Delete line item" %}'; + + if (!!row.shipped) { + delete_disabled = true; + title = '{% trans "Cannot be deleted as items have been shipped" %}'; + } else if (!!row.allocated) { + delete_disabled = true; + title = '{% trans "Cannot be deleted as items have been allocated" %}'; + } + + // Prevent deletion of the line item if items have been allocated or shipped! + html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, title, {disabled: delete_disabled}); html += `
`; @@ -1662,15 +2373,30 @@ function loadSalesOrderLineItemTable(table, options={}) { $(table).find('.button-add-by-sn').click(function() { var pk = $(this).attr('pk'); - // TODO: Migrate this form to the API forms inventreeGet(`/api/order/so-line/${pk}/`, {}, { success: function(response) { - launchModalForm('{% url "so-assign-serials" %}', { - success: reloadTable, - data: { - line: pk, - part: response.part, + + constructForm(`/api/order/so/${options.order}/allocate-serials/`, { + method: 'POST', + title: '{% trans "Allocate Serial Numbers" %}', + fields: { + line_item: { + value: pk, + hidden: true, + }, + quantity: {}, + serial_numbers: {}, + shipment: { + filters: { + order: options.order, + shipped: false, + }, + auto_fill: true, + } + }, + onSuccess: function() { + $(table).bootstrapTable('refresh'); } }); } @@ -1684,61 +2410,19 @@ function loadSalesOrderLineItemTable(table, options={}) { var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); - // Quantity remaining to be allocated - var remaining = (line_item.quantity || 0) - (line_item.allocated || 0); - - if (remaining < 0) { - remaining = 0; - } - - var fields = { - // SalesOrderLineItem reference - line: { - hidden: true, - value: pk, - }, - item: { - filters: { - part_detail: true, - location_detail: true, - in_stock: true, - part: line_item.part, - exclude_so_allocation: options.order, - }, - auto_fill: true, - onSelect: function(data, field, opts) { - // Quantity available from this stock item - - if (!('quantity' in data)) { - return; - } - - // Calculate the available quantity - var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); - - // Maximum amount that we need - var desired = Math.min(available, remaining); - - updateFieldValue('quantity', desired, {}, opts); - } - }, - quantity: { - value: remaining, - }, - }; - - // Exclude expired stock? - if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) { - fields.item.filters.expired = false; - } - - constructForm( - `/api/order/so-allocation/`, + allocateStockToSalesOrder( + options.order, + [ + line_item + ], { - method: 'POST', - fields: fields, - title: '{% trans "Allocate Stock Item" %}', - onSuccess: reloadTable, + success: function() { + // Reload this table + $(table).bootstrapTable('refresh'); + + // Reload the pending shipment table + $('#pending-shipments-table').bootstrapTable('refresh'); + } } ); }); @@ -1809,7 +2493,7 @@ function loadSalesOrderLineItemTable(table, options={}) { $(table).inventreeTable({ onPostBody: setupCallbacks, name: 'salesorderlineitems', - sidePagination: 'server', + sidePagination: 'client', formatNoMatches: function() { return '{% trans "No matching line items" %}'; }, @@ -1825,7 +2509,7 @@ function loadSalesOrderLineItemTable(table, options={}) { // Order is pending return row.allocated > 0; } else { - return row.fulfilled > 0; + return row.shipped > 0; } }, detailFormatter: function(index, row, element) { diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 02ea3c2c3b..d6de4fdd45 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -95,6 +95,9 @@ function serializeStockItem(pk, options={}) { }); } + options.confirm = true; + options.confirmMessage = '{% trans "Confirm Stock Serialization" %}'; + constructForm(url, options); } @@ -1275,7 +1278,14 @@ function loadStockTable(table, options) { } if (row.allocated) { - html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}'); + + if (row.serial != null && row.quantity == 1) { + html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}'); + } else if (row.allocated >= row.quantity) { + html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Stock item has been fully allocated" %}'); + } else { + html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been partially allocated" %}'); + } } if (row.belongs_to) { diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 409192f74d..6920626284 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -173,6 +173,11 @@ function getAvailableTableFilters(tableKey) { title: '{% trans "Is allocated" %}', description: '{% trans "Item has been allocated" %}', }, + available: { + type: 'bool', + title: '{% trans "Available" %}', + description: '{% trans "Stock is available for use" %}', + }, cascade: { type: 'bool', title: '{% trans "Include sublocations" %}', @@ -305,6 +310,7 @@ function getAvailableTableFilters(tableKey) { }, }; } + // Filters for the PurchaseOrder table if (tableKey == 'purchaseorder') { @@ -341,6 +347,15 @@ function getAvailableTableFilters(tableKey) { }; } + if (tableKey == 'salesorderlineitem') { + return { + completed: { + type: 'bool', + title: '{% trans "Completed" %}', + }, + }; + } + if (tableKey == 'supplier-part') { return { active: { diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 48644e1889..2b16a9bb05 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -134,9 +134,10 @@ class RuleSet(models.Model): 'sales_order': [ 'company_company', 'order_salesorder', + 'order_salesorderallocation', 'order_salesorderattachment', 'order_salesorderlineitem', - 'order_salesorderallocation', + 'order_salesordershipment', ] }