Scheduling improvements (#3564)

* Handle case where initial API call fails

* Error if scheduling fails to retrieve

* Visual improvements for scheduling graph:

- Fixes for vertical scales
- Add "minimum stock level" line

* Refactor / improve query for list of BOM items a part can exist in

* Remove stock column from "substitute part" dialog

- Stock quantity no longer available in this serailizer

* Add a button to reload part scheduling information

* Add extra information to part scheduling API

- Include "speculative" quantity drawdown for build orders

* Add table of scheduling data

* Improved chart display

- Adds "minimum" and "maximum" expected values
- Adds table of scheduling information

* Bump API version

* Improve table rendering

* Improve axis scaling

* Add ability to dynamically refresh schedling data

* JS linting

* JS fix
This commit is contained in:
Oliver 2022-08-18 11:36:02 +10:00 committed by GitHub
parent 1d4a20d1d4
commit 32b11ec5af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 355 additions and 76 deletions

View File

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

View File

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

View File

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

View File

@ -40,6 +40,11 @@
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Scheduling" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button class='btn btn-primary' type='button' id='btn-schedule-reload' title='{% trans "Refresh scheduling data" %}'>
<span class='fas fa-redo-alt'></span> {% trans "Refresh" %}
</button>
</div>
</div>
</div>
<div class='panel-content'>
@ -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

View File

@ -4,3 +4,15 @@
<div id='part-schedule' style='max-height: 300px;'>
<canvas id='part-schedule-chart' width='100%' style='max-height: 300px;'></canvas>
</div>
<table class='table table-striped table-condensed' id='part-schedule-table'>
<thead>
<tr>
<th>{% trans "Link" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Scheduled Quantity" %}</th>
</tr>
</thead>
<tbody></tbody>
</table>

View File

@ -519,7 +519,6 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
</a>
</td>
<td id='description-${pk}'><em>${part.description}</em></td>
<td id='stock-${pk}'><em>${part.stock}</em></td>
<td>${buttons}</td>
</tr>
`;
@ -552,7 +551,6 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Stock" %}</th>
<th><!-- Actions --></th>
</tr>
</thead>

View File

@ -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 = '<em>{% trans "No date specified" %}</em>';
date_string += `<span class='fas fa-exclamation-circle icon-red float-right' title='{% trans "No date specified" %}'></span>`;
} else if (date < today) {
date_string += `<span class='fas fa-exclamation-circle icon-yellow float-right' title='{% trans "Specified date is in the past" %}'></span>`;
}
var quantity_string = entry.quantity + entry.speculative_quantity;
if (entry.speculative_quantity != 0) {
quantity_string += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Speculative" %}'></span>`;
}
// Add an entry to the scheduling table
table_html += `
<tr>
<td><a href="${entry.url}">${entry.label}</a></td>
<td>${entry.title}</td>
<td>${date_string}</td>
<td>${quantity_string}</td>
</tr>
`;
// 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 = `
<div class='alert alert-block alert-info'>
{% trans "No scheduling information available for this part" %}.<br>
{% trans "No scheduling information available for this part" %}.
</div>`;
if (was_error) {
message = `
<div class='alert alert-block alert-danger'>
{% trans "Error fetching scheduling information for this part" %}.
</div>
`;
}
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: {