diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index f68a04cfa3..aad384331d 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -2,11 +2,14 @@
# InvenTree API version
-INVENTREE_API_VERSION = 70
+INVENTREE_API_VERSION = 71
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
+v71 -> 2022-08-18 : https://github.com/inventree/InvenTree/pull/3564
+ - Updates to the "part scheduling" API endpoint
+
v70 -> 2022-08-02 : https://github.com/inventree/InvenTree/pull/3451
- Adds a 'depth' parameter to the PartCategory list API
- Adds a 'depth' parameter to the StockLocation list API
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 1d5b10ac61..522f09e10a 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -1,6 +1,6 @@
"""Provides a JSON API for the Part app."""
-import datetime
+import functools
from decimal import Decimal, InvalidOperation
from django.db import transaction
@@ -474,27 +474,27 @@ class PartScheduling(RetrieveAPI):
def retrieve(self, request, *args, **kwargs):
"""Return scheduling information for the referenced Part instance"""
- today = datetime.datetime.now().date()
part = self.get_object()
schedule = []
- def add_schedule_entry(date, quantity, title, label, url):
+ def add_schedule_entry(date, quantity, title, label, url, speculative_quantity=0):
"""Check if a scheduled entry should be added:
- date must be non-null
- date cannot be in the "past"
- quantity must not be zero
"""
- if date and date >= today and quantity != 0:
- schedule.append({
- 'date': date,
- 'quantity': quantity,
- 'title': title,
- 'label': label,
- 'url': url,
- })
+
+ schedule.append({
+ 'date': date,
+ 'quantity': quantity,
+ 'speculative_quantity': speculative_quantity,
+ 'title': title,
+ 'label': label,
+ 'url': url,
+ })
# Add purchase order (incoming stock) information
po_lines = order.models.PurchaseOrderLineItem.objects.filter(
@@ -571,23 +571,77 @@ class PartScheduling(RetrieveAPI):
and just looking at what stock items the user has actually allocated against the Build.
"""
- build_allocations = BuildItem.objects.filter(
- stock_item__part=part,
- build__status__in=BuildStatus.ACTIVE_CODES,
- )
+ # Grab a list of BomItem objects that this part might be used in
+ bom_items = BomItem.objects.filter(part.get_used_in_bom_item_filter())
- for allocation in build_allocations:
+ for bom_item in bom_items:
+ # Find a list of active builds for this BomItem
- add_schedule_entry(
- allocation.build.target_date,
- -allocation.quantity,
- _('Stock required for Build Order'),
- str(allocation.build),
- allocation.build.get_absolute_url(),
+ builds = Build.objects.filter(
+ status__in=BuildStatus.ACTIVE_CODES,
+ part=bom_item.part,
)
+ for build in builds:
+
+ if bom_item.sub_part.trackable:
+ # Trackable parts are allocated against the outputs
+ required_quantity = build.remaining * bom_item.quantity
+ else:
+ # Non-trackable parts are allocated against the build itself
+ required_quantity = build.quantity * bom_item.quantity
+
+ # Grab all allocations against the spefied BomItem
+ allocations = BuildItem.objects.filter(
+ bom_item=bom_item,
+ build=build,
+ )
+
+ # Total allocated for *this* part
+ part_allocated_quantity = 0
+
+ # Total allocated for *any* part
+ total_allocated_quantity = 0
+
+ for allocation in allocations:
+ total_allocated_quantity += allocation.quantity
+
+ if allocation.stock_item.part == part:
+ part_allocated_quantity += allocation.quantity
+
+ speculative_quantity = 0
+
+ # Consider the case where the build order is *not* fully allocated
+ if required_quantity > total_allocated_quantity:
+ speculative_quantity = -1 * (required_quantity - total_allocated_quantity)
+
+ add_schedule_entry(
+ build.target_date,
+ -part_allocated_quantity,
+ _('Stock required for Build Order'),
+ str(build),
+ build.get_absolute_url(),
+ speculative_quantity=speculative_quantity
+ )
+
+ def compare(entry_1, entry_2):
+ """Comparison function for sorting entries by date.
+
+ Account for the fact that either date might be None
+ """
+
+ date_1 = entry_1['date']
+ date_2 = entry_2['date']
+
+ if date_1 is None:
+ return -1
+ elif date_2 is None:
+ return 1
+
+ return -1 if date_1 < date_2 else 1
+
# Sort by incrementing date values
- schedule = sorted(schedule, key=lambda entry: entry['date'])
+ schedule = sorted(schedule, key=functools.cmp_to_key(compare))
return Response(schedule)
@@ -1746,28 +1800,7 @@ class BomList(ListCreateDestroyAPIView):
# Extract the part we are interested in
uses_part = Part.objects.get(pk=uses)
- # Construct the database query in multiple parts
-
- # A) Direct specification of sub_part
- q_A = Q(sub_part=uses_part)
-
- # B) BomItem is inherited and points to a "parent" of this part
- parents = uses_part.get_ancestors(include_self=False)
-
- q_B = Q(
- inherited=True,
- sub_part__in=parents
- )
-
- # C) Substitution of variant parts
- # TODO
-
- # D) Specification of individual substitutes
- # TODO
-
- q = q_A | q_B
-
- queryset = queryset.filter(q)
+ queryset = queryset.filter(uses_part.get_used_in_bom_item_filter())
except (ValueError, Part.DoesNotExist):
pass
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 95cf3f4405..6048be70e1 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -1436,6 +1436,53 @@ class Part(MetadataMixin, MPTTModel):
return parts
+ def get_used_in_bom_item_filter(self, include_inherited=True, include_variants=True, include_substitutes=True):
+ """Return a BomItem queryset which returns all BomItem instances which refer to *this* part.
+
+ As the BOM allocation logic is somewhat complicted, there are some considerations:
+
+ A) This part may be directly specified in a BomItem instance
+ B) This part may be a *variant* of a part which is directly specified in a BomItem instance
+ C) This part may be a *substitute* for a part which is directly specifed in a BomItem instance
+
+ So we construct a query for each case, and combine them...
+ """
+
+ # Cache all *parent* parts
+ parents = self.get_ancestors(include_self=False)
+
+ # Case A: This part is directly specified in a BomItem (we always use this case)
+ query = Q(
+ sub_part=self,
+ inherited=False,
+ )
+
+ if include_inherited:
+ query |= Q(
+ sub_part__in=parents,
+ inherited=True
+ )
+
+ if include_variants:
+ # Case B: This part is a *variant* of a part which is specified in a BomItem which allows variants
+ query |= Q(
+ allow_variants=True,
+ sub_part__in=parents,
+ inherited=False,
+ )
+
+ # Case C: This part is a *substitute* of a part which is directly specified in a BomItem
+ if include_substitutes:
+
+ # Grab a list of BomItem substitutes which reference this part
+ substitutes = self.substitute_items.all()
+
+ query |= Q(
+ pk__in=[substitute.bom_item.pk for substitute in substitutes],
+ )
+
+ return query
+
def get_used_in_filter(self, include_inherited=True):
"""Return a query filter for all parts that this part is used in.
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 4ac5d28d08..ab238a0859 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -40,6 +40,11 @@
@@ -427,7 +432,12 @@
// Load the "scheduling" tab
onPanelLoad('scheduling', function() {
- loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
+ var chart = loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
+
+ $('#btn-schedule-reload').click(function() {
+ chart.destroy();
+ loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
+ });
});
// Load the "suppliers" tab
diff --git a/InvenTree/part/templates/part/part_scheduling.html b/InvenTree/part/templates/part/part_scheduling.html
index 61f49f2d47..f0d4acccc6 100644
--- a/InvenTree/part/templates/part/part_scheduling.html
+++ b/InvenTree/part/templates/part/part_scheduling.html
@@ -4,3 +4,15 @@
+
+
+
+
+ {% trans "Link" %} |
+ {% trans "Description" %} |
+ {% trans "Date" %} |
+ {% trans "Scheduled Quantity" %} |
+
+
+
+
diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index dad17074dd..6193b5a127 100644
--- a/InvenTree/templates/js/translated/bom.js
+++ b/InvenTree/templates/js/translated/bom.js
@@ -519,7 +519,6 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
${part.description} |
-
${part.stock} |
${buttons} |
`;
@@ -552,7 +551,6 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
{% trans "Part" %} |
{% trans "Description" %} |
- {% trans "Stock" %} |
|
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index cbb7c77c28..4b204df45b 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -2257,6 +2257,14 @@ function initPriceBreakSet(table, options) {
}
+/*
+ * Load a chart which displays projected scheduling information for a particular part.
+ * This takes into account:
+ * - Current stock levels / availability
+ * - Upcoming / scheduled build orders
+ * - Upcoming / scheduled sales orders
+ * - Upcoming / scheduled purchase orders
+ */
function loadPartSchedulingChart(canvas_id, part_id) {
var part_info = null;
@@ -2269,16 +2277,30 @@ function loadPartSchedulingChart(canvas_id, part_id) {
}
});
+ if (!part_info) {
+ console.error(`Error loading part information for part ${part_id}`);
+ return;
+ }
+
var today = moment();
- // Create an initial entry, using the available quantity
- var stock_schedule = [
- {
- date: today,
- delta: 0,
- label: '{% trans "Current Stock" %}',
- }
- ];
+ /* Construct initial datasets for:
+ * - Scheduled quantity
+ * - Minimum speculative quantity
+ * - Maximum speculative quantity
+ */
+
+ var quantity_scheduled = [{
+ date: today,
+ delta: 0,
+ }];
+
+ // We will construct the HTML table as we go
+ var table_html = '';
+
+ // The "known" initial stock quantity
+ var initial_stock_min = part_info.in_stock;
+ var initial_stock_max = part_info.in_stock;
/* Request scheduling information for the part.
* Note that this information has already been 'curated' by the server,
@@ -2290,28 +2312,85 @@ function loadPartSchedulingChart(canvas_id, part_id) {
{
async: false,
success: function(response) {
- response.forEach(function(entry) {
- stock_schedule.push({
+
+ for (var idx = 0; idx < response.length; idx++) {
+
+ var entry = response[idx];
+ var date = entry.date != null ? moment(entry.date) : null;
+
+ var date_string = entry.date;
+
+ if (date == null) {
+ date_string = '
{% trans "No date specified" %}';
+ date_string += `
`;
+ } else if (date < today) {
+ date_string += `
`;
+ }
+
+ var quantity_string = entry.quantity + entry.speculative_quantity;
+
+ if (entry.speculative_quantity != 0) {
+ quantity_string += `
`;
+ }
+
+ // Add an entry to the scheduling table
+ table_html += `
+
+ ${entry.label} |
+ ${entry.title} |
+ ${date_string} |
+ ${quantity_string} |
+
+ `;
+
+ // If the date is unknown or in the past, we cannot make use of this information
+ // So we update the "speculative quantity"
+ if (date == null || date < today) {
+ if (entry.quantity < 0) initial_stock_min += entry.quantity;
+ if (entry.speculative_quantity < 0) initial_stock_min += entry.speculative_quantity;
+
+ if (entry.quantity > 0) initial_stock_max += entry.quantity;
+ if (entry.speculative_quantity > 0) initial_stock_max += entry.speculative_quantity;
+
+ // We do not add this entry to the graph
+ continue;
+ }
+
+ // Add an entry to the scheduled quantity
+ quantity_scheduled.push({
date: moment(entry.date),
delta: entry.quantity,
+ speculative: entry.speculative_quantity,
title: entry.title,
label: entry.label,
url: entry.url,
});
- });
+ }
+ },
+ error: function(response) {
+ console.error(`Error retrieving scheduling information for part ${part_id}`);
+ was_error = true;
}
}
);
// If no scheduling information is available for the part,
// remove the chart and display a message instead
- if (stock_schedule.length <= 1) {
+ if (quantity_scheduled.length <= 1) {
var message = `
- {% trans "No scheduling information available for this part" %}.
+ {% trans "No scheduling information available for this part" %}.
`;
+ if (was_error) {
+ message = `
+
+ {% trans "Error fetching scheduling information for this part" %}.
+
+ `;
+ }
+
var canvas_element = $('#part-schedule-chart');
canvas_element.closest('div').html(message);
@@ -2319,30 +2398,126 @@ function loadPartSchedulingChart(canvas_id, part_id) {
return;
}
- // Iterate through future "events" to calculate expected quantity
+ var y_min = 0;
+ var y_max = 0;
+ // Iterate through future "events" to calculate expected quantity values
var quantity = part_info.in_stock;
+ var speculative_min = initial_stock_min;
+ var speculative_max = initial_stock_max;
- for (var idx = 0; idx < stock_schedule.length; idx++) {
+ // Datasets for speculative quantity
+ var q_spec_min = [];
+ var q_spec_max = [];
- quantity += stock_schedule[idx].delta;
+ for (var idx = 0; idx < quantity_scheduled.length; idx++) {
- stock_schedule[idx].x = stock_schedule[idx].date.format('YYYY-MM-DD');
- stock_schedule[idx].y = quantity;
+ var speculative = quantity_scheduled[idx].speculative;
+ var date = quantity_scheduled[idx].date.format('YYYY-MM-DD');
+ var delta = quantity_scheduled[idx].delta;
+
+ // Update the running quantity
+ quantity += delta;
+
+ quantity_scheduled[idx].x = date;
+ quantity_scheduled[idx].y = quantity;
+
+ // Update minimum "speculative" quantity
+ speculative_min += delta;
+ speculative_max += delta;
+
+ if (speculative < 0) {
+ speculative_min += speculative;
+ } else if (speculative > 0) {
+ speculative_max += speculative;
+ }
+
+ q_spec_min.push({
+ x: date,
+ y: speculative_min,
+ label: 'label',
+ title: '',
+ });
+
+ q_spec_max.push({
+ x: date,
+ y: speculative_max,
+ label: 'label',
+ title: '',
+ });
+
+ // Update min / max values
+ if (quantity < y_min) y_min = quantity;
+ if (quantity > y_max) y_max = quantity;
}
var context = document.getElementById(canvas_id);
- const data = {
- datasets: [{
- label: '{% trans "Scheduled Stock Quantities" %}',
- data: stock_schedule,
- backgroundColor: 'rgb(220, 160, 80)',
- borderWidth: 2,
- borderColor: 'rgb(90, 130, 150)'
- }],
+ var data = {
+ datasets: [
+ {
+ label: '{% trans "Scheduled Stock Quantities" %}',
+ data: quantity_scheduled,
+ backgroundColor: 'rgba(160, 80, 220, 0.75)',
+ borderWidth: 3,
+ borderColor: 'rgb(160, 80, 220)'
+ },
+ {
+ label: '{% trans "Minimum Quantity" %}',
+ data: q_spec_min,
+ backgroundColor: 'rgba(220, 160, 80, 0.25)',
+ borderWidth: 2,
+ borderColor: 'rgba(220, 160, 80, 0.35)',
+ borderDash: [10, 5],
+ fill: '-1',
+ },
+ {
+ label: '{% trans "Maximum Quantity" %}',
+ data: q_spec_max,
+ backgroundColor: 'rgba(220, 160, 80, 0.25)',
+ borderWidth: 2,
+ borderColor: 'rgba(220, 160, 80, 0.35)',
+ borderDash: [10, 5],
+ fill: '-2',
+ },
+ ],
};
+ if (part_info.minimum_stock) {
+ // Construct a 'minimum stock' threshold line
+ var minimum_stock_curve = [
+ {
+ x: today.format(),
+ y: part_info.minimum_stock,
+ },
+ {
+ x: quantity_scheduled[quantity_scheduled.length - 1].x,
+ y: part_info.minimum_stock,
+ }
+ ];
+
+ data.datasets.push({
+ data: minimum_stock_curve,
+ label: '{% trans "Minimum Stock Level" %}',
+ backgroundColor: 'rgba(250, 50, 50, 0.1)',
+ borderColor: 'rgba(250, 50, 50, 0.5)',
+ borderDash: [5, 5],
+ fill: {
+ target: {
+ value: 0,
+ }
+ }
+ });
+ }
+
+ // Update the table
+ $('#part-schedule-table').find('tbody').html(table_html);
+
+ var y_range = y_max - y_min;
+
+ y_max += 0.1 * y_range;
+ y_min -= 0.1 * y_range;
+
return new Chart(context, {
type: 'scatter',
data: data,
@@ -2359,7 +2534,8 @@ function loadPartSchedulingChart(canvas_id, part_id) {
},
},
y: {
- beginAtZero: true,
+ min: y_min,
+ max: y_max,
}
},
plugins: {