From f3f3030b3773bcd9570c0e26a302cdd5630badd2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Nov 2021 00:02:03 +1100 Subject: [PATCH] Adds API endpoint to "ship" a sales order shipment --- InvenTree/order/api.py | 27 ++++++++++++++ InvenTree/order/models.py | 18 +++++++--- InvenTree/order/serializers.py | 41 +++++++++++++++++++++- InvenTree/templates/js/translated/order.js | 39 +++++++++++++++++--- InvenTree/templates/js/translated/stock.js | 3 ++ 5 files changed, 119 insertions(+), 9 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 2ee9a7694d..f4e10bff08 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -767,6 +767,32 @@ class SOShipmentDetail(generics.RetrieveUpdateAPIView): 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) @@ -829,6 +855,7 @@ order_api_urls = [ 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'), diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 37092fd0e3..10b0a45f62 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -996,6 +996,15 @@ class SalesOrderShipment(models.Model): help_text=_('Shipment tracking information'), ) + 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): """ @@ -1006,12 +1015,13 @@ class SalesOrderShipment(models.Model): 3. Set the "shipment_date" to now """ - if self.shipment_date: - # Ignore, shipment has already been sent! - return + # 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 self.allocations.all(): + for allocation in allocations: # Mark the allocation as "complete" allocation.complete_allocation(user) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index d2179d16b7..cffdbaec32 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -25,7 +25,6 @@ from InvenTree.helpers import normalize from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeMoneySerializer -from InvenTree.serializers import InvenTreeAttachmentSerializerField from InvenTree.status_codes import StockStatus import order.models @@ -617,6 +616,46 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer): ] +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 + + shipment.complete_shipment(user) + + class SOShipmentAllocationItemSerializer(serializers.Serializer): """ A serializer for allocating a single stock-item against a SalesOrder shipment diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 478bccc2a5..50d6b4444e 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -20,6 +20,7 @@ /* exported allocateStockToSalesOrder, + completeShipment, createSalesOrder, editPurchaseOrderLineItem, exportOrder, @@ -52,6 +53,26 @@ function salesOrderShipmentFields(options={}) { } +/* + * Complete a shipment + */ +function completeShipment(shipment_id) { + + constructForm(`/api/order/so/shipment/${shipment_id}/ship/`, { + method: 'POST', + title: '{% trans "Complete Shipment" %}', + fields: { + tracking_number: {}, + }, + confirm: true, + confirmMessage: '{% trans "Confirm Shipment" %}', + onSuccess: function(data) { + // TODO + } + }); +} + + // Open a dialog to create a new sales order shipment function createSalesOrderShipment(options={}) { constructForm('{% url "api-so-shipment-list" %}', { @@ -1183,6 +1204,8 @@ function loadSalesOrderShipmentTable(table, options={}) { html += makeIconButton('fa-edit icon-blue', 'button-shipment-edit', pk, '{% trans "Edit shipment" %}'); + html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}'); + html += ``; return html; @@ -1205,6 +1228,12 @@ function loadSalesOrderShipmentTable(table, options={}) { } }); }); + + $(table).find('.button-shipment-ship').click(function() { + var pk = $(this).attr('pk'); + + completeShipment(pk); + }); } $(table).inventreeTable({ @@ -1505,7 +1534,7 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { // Exclude expired stock? if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) { - fields.item.filters.expired = false; + filters.expired = false; } return filters; @@ -1781,14 +1810,16 @@ function showAllocationSubTable(index, row, element, options) { title: '{% trans "Location" %}', formatter: function(value, row, index, field) { - // Location specified - if (row.location) { + if (shipped) { + return `{% trans "Shipped to customer" %}`; + } 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" %}`; } }, }, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 5e92299f03..35a3a307ce 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -94,6 +94,9 @@ function serializeStockItem(pk, options={}) { }); } + options.confirm = true; + options.confirmMessage = '{% trans "Confirm Stock Serialization" %}'; + constructForm(url, options); }