diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 8da4135b12..c5e937b10b 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,15 @@ # InvenTree API version -INVENTREE_API_VERSION = 61 +INVENTREE_API_VERSION = 64 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v64 -> 2022-07-08 : https://github.com/inventree/InvenTree/pull/3310 + - Annotate 'on_order' quantity to BOM list API + - Allow BOM List API endpoint to be filtered by "on_order" parameter + v63 -> 2022-07-06 : https://github.com/inventree/InvenTree/pull/3301 - Allow BOM List API endpoint to be filtered by "available_stock" paramater diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index ca67ba1b35..90579a021c 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1545,6 +1545,20 @@ class BomFilter(rest_filters.FilterSet): return queryset + on_order = rest_filters.BooleanFilter(label="On order", method="filter_on_order") + + def filter_on_order(self, queryset, name, value): + """Filter the queryset based on whether each line item has any stock on order""" + + value = str2bool(value) + + if value: + queryset = queryset.filter(on_order__gt=0) + else: + queryset = queryset.filter(on_order=0) + + return queryset + class BomList(ListCreateDestroyAPIView): """API endpoint for accessing a list of BomItem objects. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 807a105af1..9e0f01c2df 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -524,6 +524,8 @@ class BomItemSerializer(InvenTreeModelSerializer): purchase_price_range = serializers.SerializerMethodField() + on_order = serializers.FloatField(read_only=True) + # Annotated fields for available stock available_stock = serializers.FloatField(read_only=True) available_substitute_stock = serializers.FloatField(read_only=True) @@ -593,6 +595,11 @@ class BomItemSerializer(InvenTreeModelSerializer): ref = 'sub_part__' + # Annotate with the total "on order" amount for the sub-part + queryset = queryset.annotate( + on_order=part.filters.annotate_on_order_quantity(ref), + ) + # Calculate "total stock" for the referenced sub_part # Calculate the "build_order_allocations" for the sub_part # Note that these fields are only aliased, not annotated @@ -719,6 +726,8 @@ class BomItemSerializer(InvenTreeModelSerializer): 'available_substitute_stock', 'available_variant_stock', + # Annotated field describing quantity on order + 'on_order', ] diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 52fbab79b4..22e43be7b6 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -297,6 +297,7 @@ stock_item: {{ item.pk }}, } }, + multi_delete: true, method: 'DELETE', title: '{% trans "Delete Test Data" %}', preFormContent: html, diff --git a/InvenTree/templates/InvenTree/notifications/notifications.html b/InvenTree/templates/InvenTree/notifications/notifications.html index b225002279..460f747191 100644 --- a/InvenTree/templates/InvenTree/notifications/notifications.html +++ b/InvenTree/templates/InvenTree/notifications/notifications.html @@ -72,6 +72,7 @@ $('#history-delete').click(function() { '{% url "api-notifications-list" %}', { method: 'DELETE', + multi_delete: true, preFormContent: html, title: '{% trans "Delete Notifications" %}', onSuccess: function() { diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index bef49961a6..2e6b53ffa7 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -109,6 +109,7 @@ function deleteAttachments(attachments, url, options={}) { constructForm(url, { method: 'DELETE', + multi_delete: true, title: '{% trans "Delete Attachments" %}', preFormContent: html, form_data: { diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index f800c10d1b..df51aae679 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -651,9 +651,10 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) { } +/* + * Delete the selected BOM items from the database + */ function deleteBomItems(items, options={}) { - /* Delete the selected BOM items from the database - */ function renderItem(item, opts={}) { @@ -696,6 +697,7 @@ function deleteBomItems(items, options={}) { constructForm('{% url "api-bom-list" %}', { method: 'DELETE', + multi_delete: true, title: '{% trans "Delete selected BOM items?" %}', form_data: { items: ids, @@ -877,6 +879,32 @@ function loadBomTable(table, options={}) { return text; }, + footerFormatter: function(data) { + + // Top-level BOM count + var top_total = 0; + + // Total BOM count + var all_total = 0; + + data.forEach(function(row) { + var q = +row['quantity'] || 0; + + all_total += q; + + if (row.part == options.parent_id) { + top_total += q; + } + }); + + var total = `${top_total}`; + + if (top_total != all_total) { + total += ` / ${all_total}`; + } + + return total; + } }); cols.push({ @@ -897,9 +925,10 @@ function loadBomTable(table, options={}) { var text = `${available_stock}`; if (available_stock <= 0) { - text = `{% trans "No Stock Available" %}`; + text += ``; } else { var extra = ''; + if ((substitute_stock > 0) && (variant_stock > 0)) { extra = '{% trans "Includes variant and substitute stock" %}'; } else if (variant_stock > 0) { @@ -913,6 +942,10 @@ function loadBomTable(table, options={}) { } } + if (row.on_order && row.on_order > 0) { + text += ``; + } + return renderLink(text, url); } }); @@ -1010,7 +1043,36 @@ function loadBomTable(table, options={}) { can_build = available / row.quantity; } - return formatDecimal(can_build, 2); + var text = formatDecimal(can_build, 2); + + // Take "on order" quantity into account + if (row.on_order && row.on_order > 0 && row.quantity > 0) { + available += row.on_order; + can_build = available / row.quantity; + + text += ``; + } + + return text; + }, + footerFormatter: function(data) { + var can_build = null; + + data.forEach(function(row) { + if (row.part == options.parent_id && row.quantity > 0) { + var cb = availableQuantity(row) / row.quantity; + + if (can_build == null || cb < can_build) { + can_build = cb; + } + } + }); + + if (can_build == null) { + can_build = '-'; + } + + return can_build; }, sorter: function(valA, valB, rowA, rowB) { // Function to sort the "can build" quantity @@ -1131,6 +1193,7 @@ function loadBomTable(table, options={}) { parentIdField: 'parentId', treeShowField: 'sub_part', showColumns: true, + showFooter: true, name: 'bom', sortable: true, search: true, diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index dd87c2ef83..8a167f5c20 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -263,6 +263,7 @@ function deleteSupplierParts(parts, options={}) { constructForm('{% url "api-supplier-part-list" %}', { method: 'DELETE', + multi_delete: true, title: '{% trans "Delete Supplier Parts" %}', preFormContent: html, form_data: { @@ -491,6 +492,7 @@ function deleteManufacturerParts(selections, options={}) { constructForm('{% url "api-manufacturer-part-list" %}', { method: 'DELETE', + multi_delete: true, title: '{% trans "Delete Manufacturer Parts" %}', preFormContent: html, form_data: { @@ -538,6 +540,7 @@ function deleteManufacturerPartParameters(selections, options={}) { constructForm('{% url "api-manufacturer-part-parameter-list" %}', { method: 'DELETE', + multi_delete: true, title: '{% trans "Delete Parameters" %}', preFormContent: html, form_data: { diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 69a4c5460d..02247bef7a 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -250,30 +250,40 @@ function constructChangeForm(fields, options) { */ function constructDeleteForm(fields, options) { - // Request existing data from the API endpoint - // This data can be used to render some information on the form - $.ajax({ - url: options.url, - type: 'GET', - contentType: 'application/json', - dataType: 'json', - accepts: { - json: 'application/json', - }, - success: function(data) { + // If we are deleting a specific "instance" (i.e. a single object) + // then we request the instance information first - // Store the instance data - options.instance = data; + // However we may be performing a "multi-delete" (against a list endpoint), + // in which case we do not want to perform such a request! - constructFormBody(fields, options); - }, - error: function(xhr) { - // TODO: Handle error here - console.error(`Error in constructDeleteForm at '${options.url}`); + if (options.multi_delete) { + constructFormBody(fields, options); + } else { + // Request existing data from the API endpoint + // This data can be used to render some information on the form + $.ajax({ + url: options.url, + type: 'GET', + contentType: 'application/json', + dataType: 'json', + accepts: { + json: 'application/json', + }, + success: function(data) { - showApiError(xhr, options.url); - } - }); + // Store the instance data + options.instance = data; + + constructFormBody(fields, options); + }, + error: function(xhr) { + // TODO: Handle error here + console.error(`Error in constructDeleteForm at '${options.url}`); + + showApiError(xhr, options.url); + } + }); + } } diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index c13ee3fb1a..e817c17af8 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -63,6 +63,10 @@ function getAvailableTableFilters(tableKey) { type: 'bool', title: '{% trans "Has Available Stock" %}', }, + on_order: { + type: 'bool', + title: '{% trans "On Order" %}', + }, validated: { type: 'bool', title: '{% trans "Validated" %}',