Scheduling information is now calculated on the server, and provided via a new API endpoint

- Much simpler than sequencing multiple API calls
This commit is contained in:
Oliver 2022-03-01 22:54:49 +11:00
parent dfd6684699
commit 776dffe779
3 changed files with 161 additions and 152 deletions

View File

@ -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

View File

@ -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'),

View File

@ -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