mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13:05:42 +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:
@ -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>
|
||||
|
Reference in New Issue
Block a user