mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +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