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" %}',