mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
1d4a20d1d4
commit
32b11ec5af
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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: {
|
||||
|
Loading…
Reference in New Issue
Block a user