2
0
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:
Oliver
2022-08-18 11:36:02 +10:00
committed by GitHub
parent 1d4a20d1d4
commit 32b11ec5af
7 changed files with 355 additions and 76 deletions

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>