2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 20:16:44 +00:00

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
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 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 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 PartCategory list API
- Adds a 'depth' parameter to the StockLocation 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.""" """Provides a JSON API for the Part app."""
import datetime import functools
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.db import transaction from django.db import transaction
@ -474,23 +474,23 @@ class PartScheduling(RetrieveAPI):
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
"""Return scheduling information for the referenced Part instance""" """Return scheduling information for the referenced Part instance"""
today = datetime.datetime.now().date()
part = self.get_object() part = self.get_object()
schedule = [] 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: """Check if a scheduled entry should be added:
- date must be non-null - date must be non-null
- date cannot be in the "past" - date cannot be in the "past"
- quantity must not be zero - quantity must not be zero
""" """
if date and date >= today and quantity != 0:
schedule.append({ schedule.append({
'date': date, 'date': date,
'quantity': quantity, 'quantity': quantity,
'speculative_quantity': speculative_quantity,
'title': title, 'title': title,
'label': label, 'label': label,
'url': url, 'url': url,
@ -571,23 +571,77 @@ class PartScheduling(RetrieveAPI):
and just looking at what stock items the user has actually allocated against the Build. and just looking at what stock items the user has actually allocated against the Build.
""" """
build_allocations = BuildItem.objects.filter( # Grab a list of BomItem objects that this part might be used in
stock_item__part=part, bom_items = BomItem.objects.filter(part.get_used_in_bom_item_filter())
build__status__in=BuildStatus.ACTIVE_CODES,
for bom_item in bom_items:
# Find a list of active builds for this BomItem
builds = Build.objects.filter(
status__in=BuildStatus.ACTIVE_CODES,
part=bom_item.part,
) )
for allocation in build_allocations: 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( add_schedule_entry(
allocation.build.target_date, build.target_date,
-allocation.quantity, -part_allocated_quantity,
_('Stock required for Build Order'), _('Stock required for Build Order'),
str(allocation.build), str(build),
allocation.build.get_absolute_url(), 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 # 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) return Response(schedule)
@ -1746,28 +1800,7 @@ class BomList(ListCreateDestroyAPIView):
# Extract the part we are interested in # Extract the part we are interested in
uses_part = Part.objects.get(pk=uses) uses_part = Part.objects.get(pk=uses)
# Construct the database query in multiple parts queryset = queryset.filter(uses_part.get_used_in_bom_item_filter())
# 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)
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass pass

View File

@ -1436,6 +1436,53 @@ class Part(MetadataMixin, MPTTModel):
return parts 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): def get_used_in_filter(self, include_inherited=True):
"""Return a query filter for all parts that this part is used in. """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'> <div class='d-flex flex-wrap'>
<h4>{% trans "Part Scheduling" %}</h4> <h4>{% trans "Part Scheduling" %}</h4>
{% include "spacer.html" %} {% 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> </div>
<div class='panel-content'> <div class='panel-content'>
@ -427,8 +432,13 @@
// Load the "scheduling" tab // Load the "scheduling" tab
onPanelLoad('scheduling', function() { onPanelLoad('scheduling', function() {
var chart = loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
$('#btn-schedule-reload').click(function() {
chart.destroy();
loadPartSchedulingChart('part-schedule-chart', {{ part.pk }}); loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
}); });
});
// Load the "suppliers" tab // Load the "suppliers" tab
onPanelLoad('suppliers', function() { onPanelLoad('suppliers', function() {

View File

@ -4,3 +4,15 @@
<div id='part-schedule' style='max-height: 300px;'> <div id='part-schedule' style='max-height: 300px;'>
<canvas id='part-schedule-chart' width='100%' style='max-height: 300px;'></canvas> <canvas id='part-schedule-chart' width='100%' style='max-height: 300px;'></canvas>
</div> </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> </a>
</td> </td>
<td id='description-${pk}'><em>${part.description}</em></td> <td id='description-${pk}'><em>${part.description}</em></td>
<td id='stock-${pk}'><em>${part.stock}</em></td>
<td>${buttons}</td> <td>${buttons}</td>
</tr> </tr>
`; `;
@ -552,7 +551,6 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
<tr> <tr>
<th>{% trans "Part" %}</th> <th>{% trans "Part" %}</th>
<th>{% trans "Description" %}</th> <th>{% trans "Description" %}</th>
<th>{% trans "Stock" %}</th>
<th><!-- Actions --></th> <th><!-- Actions --></th>
</tr> </tr>
</thead> </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) { function loadPartSchedulingChart(canvas_id, part_id) {
var part_info = null; 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(); var today = moment();
// Create an initial entry, using the available quantity /* Construct initial datasets for:
var stock_schedule = [ * - Scheduled quantity
{ * - Minimum speculative quantity
* - Maximum speculative quantity
*/
var quantity_scheduled = [{
date: today, date: today,
delta: 0, delta: 0,
label: '{% trans "Current Stock" %}', }];
}
]; // 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. /* Request scheduling information for the part.
* Note that this information has already been 'curated' by the server, * Note that this information has already been 'curated' by the server,
@ -2290,28 +2312,85 @@ function loadPartSchedulingChart(canvas_id, part_id) {
{ {
async: false, async: false,
success: function(response) { 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), date: moment(entry.date),
delta: entry.quantity, delta: entry.quantity,
speculative: entry.speculative_quantity,
title: entry.title, title: entry.title,
label: entry.label, label: entry.label,
url: entry.url, 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, // If no scheduling information is available for the part,
// remove the chart and display a message instead // remove the chart and display a message instead
if (stock_schedule.length <= 1) { if (quantity_scheduled.length <= 1) {
var message = ` var message = `
<div class='alert alert-block alert-info'> <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>`; </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'); var canvas_element = $('#part-schedule-chart');
canvas_element.closest('div').html(message); canvas_element.closest('div').html(message);
@ -2319,30 +2398,126 @@ function loadPartSchedulingChart(canvas_id, part_id) {
return; 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 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'); var speculative = quantity_scheduled[idx].speculative;
stock_schedule[idx].y = quantity; 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); var context = document.getElementById(canvas_id);
const data = { var data = {
datasets: [{ datasets: [
{
label: '{% trans "Scheduled Stock Quantities" %}', label: '{% trans "Scheduled Stock Quantities" %}',
data: stock_schedule, data: quantity_scheduled,
backgroundColor: 'rgb(220, 160, 80)', 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, borderWidth: 2,
borderColor: 'rgb(90, 130, 150)' 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, { return new Chart(context, {
type: 'scatter', type: 'scatter',
data: data, data: data,
@ -2359,7 +2534,8 @@ function loadPartSchedulingChart(canvas_id, part_id) {
}, },
}, },
y: { y: {
beginAtZero: true, min: y_min,
max: y_max,
} }
}, },
plugins: { plugins: {