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 @@

{% trans "Part Scheduling" %}

{% include "spacer.html" %} +
+ +
@@ -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: {