From e1668c86620b88f6329b10dcd60374edde46d249 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Dec 2021 23:52:53 +1100 Subject: [PATCH] More stuffs: - Allow filtering of salesorderlineitem by "completed" status - Allow deletion of (empty) shipment - Show which items are going to be shipped --- InvenTree/order/api.py | 59 ++++++- InvenTree/order/models.py | 48 +++++- InvenTree/order/serializers.py | 17 ++ .../order/templates/order/order_base.html | 12 ++ .../order/purchase_order_detail.html | 6 +- .../templates/order/sales_order_base.html | 29 ++-- InvenTree/templates/js/translated/build.js | 1 + InvenTree/templates/js/translated/order.js | 157 +++++++++++++----- .../templates/js/translated/table_filters.js | 10 ++ 9 files changed, 283 insertions(+), 56 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index f4e10bff08..4973c15785 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -551,6 +551,39 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView): 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 + + class SOLineItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of SalesOrderLineItem objects. @@ -558,6 +591,7 @@ class SOLineItemList(generics.ListCreateAPIView): queryset = models.SalesOrderLineItem.objects.all() serializer_class = serializers.SOLineItemSerializer + filterset_class = SOLineItemFilter def get_serializer(self, *args, **kwargs): @@ -620,6 +654,28 @@ class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): 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.SalesOrderShipmentCompleteSerializer + + 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 SalesOrderAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items against a SalesOrder @@ -758,7 +814,7 @@ class SOShipmentList(generics.ListCreateAPIView): ] -class SOShipmentDetail(generics.RetrieveUpdateAPIView): +class SOShipmentDetail(generics.RetrieveUpdateDestroyAPIView): """ API detail endpooint for SalesOrderShipment model """ @@ -863,6 +919,7 @@ order_api_urls = [ # 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'^.*$', SODetail.as_view(), name='api-so-detail'), ])), diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 0e5b10cf98..8c21c311f1 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -363,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): @@ -601,8 +620,7 @@ class SalesOrder(Order): and mark it as "shipped" if so. """ - return all([line.is_completed() for line in self.lines.all()]) - + return self.lines.count() > 0 and all([line.is_completed() for line in self.lines.all()]) def can_cancel(self): """ @@ -635,6 +653,30 @@ 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() + class PurchaseOrderAttachment(InvenTreeAttachment): """ diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index f86aae8163..3b33d4a16f 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -489,6 +489,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): 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) @@ -527,6 +529,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): 'part', 'part_detail', 'shipment', + 'shipment_date', ] @@ -734,6 +737,20 @@ class SOShipmentAllocationItemSerializer(serializers.Serializer): return data +class SalesOrderCompleteSerializer(serializers.Serializer): + """ + DRF serializer for manually marking a sales order as complete + """ + + def save(self): + + request = self.context['request'] + order = self.context['order'] + data = self.validated_data + + + + class SOShipmentAllocationSerializer(serializers.Serializer): """ DRF serializer for allocation of stock items against a sales order / shipment 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..80ff5bbd97 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,18 @@ src="{% static 'img/blank_image.png' %}" {% endif %} + + + + + {% if order.link %} @@ -149,13 +161,6 @@ src="{% static 'img/blank_image.png' %}" {% endif %} - {% if order.status == PurchaseOrderStatus.COMPLETE %} - - - - - - {% endif %} {% if order.responsible %} @@ -203,10 +208,8 @@ $("#cancel-order").click(function() { }); }); -$("#ship-order").click(function() { - launchModalForm("{% url 'so-ship' order.id %}", { - reload: true, - }); +$("#complete-order").click(function() { + completeSalesOrder({{ order.pk }}); }); {% if report_enabled %} diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index c19a5b69d2..02b2ff5321 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1413,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, }, diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index e44eed9589..64c4f97645 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -41,6 +41,9 @@ function salesOrderShipmentFields(options={}) { var fields = { order: {}, reference: {}, + tracking_number: { + icon: 'fa-hashtag', + }, }; // If order is specified, hide the order field @@ -58,19 +61,75 @@ function salesOrderShipmentFields(options={}) { */ 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) { - // Reload tables - $('#so-lines-table').bootstrapTable('refresh'); - $('#pending-shipments-table').bootstrapTable('refresh'); - $('#completed-shipments-table').bootstrapTable('refresh'); + // 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" %} +
{{ 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 %} +
{{ order.shipment_date }}{{ order.shipped_by }}
{% trans "Received" %}{{ order.complete_date }}{{ order.received_by }}
+ + + + + + + + `; + + 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'); + } + }); } }); } @@ -393,7 +452,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" %}', @@ -1256,6 +1317,10 @@ function loadSalesOrderShipmentTable(table, options={}) { 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; @@ -1268,10 +1333,12 @@ function loadSalesOrderShipmentTable(table, options={}) { $(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: { - reference: {}, - }, + fields: fields, title: '{% trans "Edit Shipment" %}', onSuccess: function() { $(table).bootstrapTable('refresh'); @@ -1284,6 +1351,18 @@ function loadSalesOrderShipmentTable(table, options={}) { 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({ @@ -1510,14 +1589,6 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { }, value: options.shipment || null, auto_fill: true, - secondary: { - title: '{% trans "New Shipment" %}', - fields: function() { - return salesOrderShipmentFields({ - order: order_id - }); - } - } } }, preFormContent: html, @@ -1854,7 +1925,7 @@ function showAllocationSubTable(index, row, element, options) { table.bootstrapTable({ onPostBody: setupCallbacks, data: row.allocations, - showHeader: false, + showHeader: true, columns: [ { field: 'part_detail', @@ -1865,7 +1936,7 @@ function showAllocationSubTable(index, row, element, options) { }, { field: 'allocated', - title: '{% trans "Quantity" %}', + title: '{% trans "Stock Item" %}', formatter: function(value, row, index, field) { var text = ''; @@ -1883,8 +1954,8 @@ function showAllocationSubTable(index, row, element, options) { title: '{% trans "Location" %}', formatter: function(value, row, index, field) { - if (shipped) { - return `{% trans "Shipped to customer" %}`; + if (row.shipment_date) { + return `{% trans "Shipped to customer" %} - ${row.shipment_date}`; } else if (row.location) { // Location specified return renderLink( @@ -1896,21 +1967,17 @@ function showAllocationSubTable(index, row, element, options) { } }, }, - // 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 (!shipped) { + 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" %}'); } @@ -2017,7 +2084,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 }}; @@ -2228,7 +2295,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 += `
`; diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index e7f5dc2a4d..6920626284 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -310,6 +310,7 @@ function getAvailableTableFilters(tableKey) { }, }; } + // Filters for the PurchaseOrder table if (tableKey == 'purchaseorder') { @@ -346,6 +347,15 @@ function getAvailableTableFilters(tableKey) { }; } + if (tableKey == 'salesorderlineitem') { + return { + completed: { + type: 'bool', + title: '{% trans "Completed" %}', + }, + }; + } + if (tableKey == 'supplier-part') { return { active: {