diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 678f69d228..c3d34a4dfd 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,14 @@ import common.models INVENTREE_SW_VERSION = "0.7.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 27 +INVENTREE_API_VERSION = 28 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v28 -> 2022-03-01 + - Adds "scheduling" endpoint for predicted stock scheduling information + v27 -> 2022-02-28 - Adds target_date field to individual line items for purchase orders and sales orders diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 954060c456..6564568fd3 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -5,6 +5,8 @@ Provides a JSON API for the Part app # -*- coding: utf-8 -*- from __future__ import unicode_literals +import datetime + from django.conf.urls import url, include from django.http import JsonResponse from django.db.models import Q, F, Count, Min, Max, Avg @@ -38,14 +40,15 @@ from company.models import Company, ManufacturerPart, SupplierPart from stock.models import StockItem, StockLocation from common.models import InvenTreeSetting -from build.models import Build +from build.models import Build, BuildItem +import order.models from . import serializers as part_serializers from InvenTree.helpers import str2bool, isNull, increment from InvenTree.api import AttachmentMixin -from InvenTree.status_codes import BuildStatus +from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus class CategoryList(generics.ListCreateAPIView): @@ -427,6 +430,137 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView): ] +class PartScheduling(generics.RetrieveAPIView): + """ + API endpoint for delivering "scheduling" information about a given part via the API. + + Returns a chronologically ordered list about future "scheduled" events, + concerning stock levels for the part: + + - Purchase Orders (incoming stock) + - Sales Orders (outgoing stock) + - Build Orders (incoming completed stock) + - Build Orders (outgoing allocated stock) + """ + + queryset = Part.objects.all() + + def retrieve(self, request, *args, **kwargs): + + today = datetime.datetime.now().date() + + part = self.get_object() + + schedule = [] + + def add_schedule_entry(date, quantity, label, url): + """ + 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, + 'url': url, + 'label': label, + }) + + # Add purchase order (incoming stock) information + po_lines = order.models.PurchaseOrderLineItem.objects.filter( + part__part=part, + order__status__in=PurchaseOrderStatus.OPEN, + ) + + for line in po_lines: + + target_date = line.target_date or line.order.target_date + + quantity = max(line.quantity - line.received, 0) + + add_schedule_entry( + target_date, + quantity, + str(line.order), + line.order.get_absolute_url() + ) + + # Add sales order (outgoing stock) information + so_lines = order.models.SalesOrderLineItem.objects.filter( + part=part, + order__status__in=SalesOrderStatus.OPEN, + ) + + for line in so_lines: + + target_date = line.target_date or line.order.target_date + + quantity = max(line.quantity - line.shipped, 0) + + add_schedule_entry( + target_date, + -quantity, + str(line.order), + line.order.get_absolute_url(), + ) + + # Add build orders (incoming stock) information + build_orders = Build.objects.filter( + part=part, + status__in=BuildStatus.ACTIVE_CODES + ) + + for build in build_orders: + + quantity = max(build.quantity - build.completed, 0) + + add_schedule_entry( + build.target_date, + quantity, + str(build), + build.get_absolute_url(), + ) + + """ + Add build order allocation (outgoing stock) information. + + Here we need some careful consideration: + + - 'Tracked' stock items are removed from stock when the individual Build Output is completed + - 'Untracked' stock items are removed from stock when the Build Order is completed + + The 'simplest' approach here is to look at existing BuildItem allocations which reference this part, + and "schedule" them for removal at the time of build order completion. + + This assumes that the user is responsible for correctly allocating parts. + + However, it has the added benefit of side-stepping the various BOM substition options, + 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, + ) + + for allocation in build_allocations: + + add_schedule_entry( + allocation.build.target_date, + -allocation.quantity, + str(allocation.build), + allocation.build.get_absolute_url(), + ) + + # Sort by incrementing date values + schedule = sorted(schedule, key=lambda entry: entry['date']) + + return Response(schedule) + + class PartSerialNumberDetail(generics.RetrieveAPIView): """ API endpoint for returning extra serial number information about a particular part @@ -1715,6 +1849,9 @@ part_api_urls = [ # Endpoint for extra serial number information url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'), + # Endpoint for future scheduling information + url(r'^scheduling/', PartScheduling.as_view(), name='api-part-scheduling'), + # Endpoint for duplicating a BOM for the specific Part url(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'), diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 4816ef2a9e..2f3a8b29e8 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -2002,158 +2002,27 @@ function loadPartSchedulingChart(canvas_id, part_id) { } ]; - function addScheduleEntry(date, delta, label, url) { - // Adds a new entry to the schedule - - // First, iterate through to find an insertion index (based on date) - var found = false; - - for (var idx = 0; idx < stock_schedule.length; idx++) { - var entry = stock_schedule[idx]; - - if (date < entry.date) { - stock_schedule.splice(idx, 0, { - date: date, - delta: delta, - label: label, - url: url, + /* Request scheduling information for the part. + * Note that this information has already been 'curated' by the server, + * and arranged in increasing chronological order + */ + inventreeGet( + `/api/part/${part_id}/scheduling/`, + {}, + { + async: false, + success: function(response) { + response.forEach(function(entry) { + stock_schedule.push({ + date: moment(entry.date), + delta: entry.quantity, + label: entry.label, + url: entry.url, + }) }); - - found = true; - break; } } - - if (!found) { - stock_schedule.push({ - date: date, - delta: delta, - label: label, - url: url, - }); - } - } - - // Extract purchase order information from the server - if (part_info.purchaseable) { - inventreeGet( - `/api/order/po-line/`, - { - pending: true, - base_part: part_id, - order_detail: true, - }, - { - async: false, - success: function(line_items) { - - line_items.forEach(function(line_item) { - - // Extract target_date information from the response. - // If the line_item does not have an individual target date, maybe the parent order does? - var target_date = line_item.target_date || line_item.order_detail.target_date; - - // How many to receive? - var delta = Math.max(line_item.quantity - line_item.received, 0); - - // TODO: What do we do if there is no target_date set for a PO line item? - // TODO: Do we just ignore it?? - - if (target_date && delta > 0) { - - var td = moment(target_date); - - if (td >= today) { - // TODO: Improve labels for purchase order lines - addScheduleEntry(td, delta, "Purchase Order", '/index/'); - - } else { - // Ignore any entries that are in the "past" - // TODO: Can we better handle this case? - } - } - }); - } - } - ); - } - - // Extract sales order information from the server - if (part_info.salable) { - inventreeGet( - `/api/order/so-line/`, - { - part: part_id, - pending: true, - order_detail: true, - }, - { - async: false, - success: function(line_items) { - - line_items.forEach(function(line_item) { - - // Extract target_date information from the response. - // If the line_item does not have an individual target date, maybe the parent order does? - var target_date = line_item.target_date || line_item.order_detail.target_date; - - var delta = Math.max(line_item.quantity - line_item.shipped, 0); - - // TODO: What do we do if there is no target_date set for a PO line item? - // TODO: Do we just ignore it?? - - if (target_date && delta > 0) { - var td = moment(target_date); - - if (td >= today) { - // TODO: Improve labels for sales order items - addScheduleEntry(td, -delta, "Sales Order", '/index/'); - } else { - // Ignore any entries that are in the "past" - // TODO: Can we better handle this case? - } - } - }); - } - } - ); - } - - // Request build orders for this part - if (part_info.assembly) { - inventreeGet( - `/api/build/`, - { - part: part_id, - active: true, - }, - { - async: false, - success: function(build_orders) { - - build_orders.forEach(function(build_order) { - - var target_date = build_order.target_date; - - var delta = Math.max(build_order.quantity - build_order.completed, 0); - - // TODO: How do we handle the case where the build order does not have a target date?? - // TODO: Do we just ignore it? - - if (target_date && delta > 0) { - var td = moment(target_date); - - if (td >= today) { - addScheduleEntry(td, delta, "Build Order", ""); - } else { - // TODO: Handle case where the build order is in the "past" - } - } - }); - } - } - ) - } + ); // Iterate through future "events" to calculate expected quantity