Merge pull request #2695 from SchrodingersGat/scheduling

[WIP] Scheduling
This commit is contained in:
Oliver 2022-03-09 12:06:55 +11:00 committed by GitHub
commit fda556c289
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 346 additions and 14399 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/*!
* chartjs-adapter-moment v1.0.0
* https://www.chartjs.org
* (c) 2021 chartjs-adapter-moment Contributors
* Released under the MIT license
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("moment"),require("chart.js")):"function"==typeof define&&define.amd?define(["moment","chart.js"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).moment,e.Chart)}(this,(function(e,t){"use strict";function n(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}var f=n(e);const a={datetime:"MMM D, YYYY, h:mm:ss a",millisecond:"h:mm:ss.SSS a",second:"h:mm:ss a",minute:"h:mm a",hour:"hA",day:"MMM D",week:"ll",month:"MMM YYYY",quarter:"[Q]Q - YYYY",year:"YYYY"};t._adapters._date.override("function"==typeof f.default?{_id:"moment",formats:function(){return a},parse:function(e,t){return"string"==typeof e&&"string"==typeof t?e=f.default(e,t):e instanceof f.default||(e=f.default(e)),e.isValid()?e.valueOf():null},format:function(e,t){return f.default(e).format(t)},add:function(e,t,n){return f.default(e).add(t,n).valueOf()},diff:function(e,t,n){return f.default(e).diff(f.default(t),n)},startOf:function(e,t,n){return e=f.default(e),"isoWeek"===t?(n=Math.trunc(Math.min(Math.max(0,n),6)),e.isoWeekday(n).startOf("day").valueOf()):e.startOf(t).valueOf()},endOf:function(e,t){return f.default(e).endOf(t).valueOf()}}:{})}));
//# sourceMappingURL=chartjs-adapter-moment.min.js.map

File diff suppressed because one or more lines are too long

View File

@ -72,7 +72,7 @@ class ViewTests(TestCase):
"""
# Change this number as more javascript files are added to the index page
N_SCRIPT_FILES = 35
N_SCRIPT_FILES = 36
content = self.get_index_page()

View File

@ -12,11 +12,14 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 28
INVENTREE_API_VERSION = 29
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v29 -> 2022-03-08
- Adds "scheduling" endpoint for predicted stock scheduling information
v28 -> 2022-03-04
- Adds an API endpoint for auto allocation of stock items against a build order
- Ref: https://github.com/inventree/InvenTree/pull/2713

View File

@ -1253,7 +1253,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
('MM/DD/YYYY', '02/22/2022'),
('MMM DD YYYY', 'Feb 22 2022'),
]
}
},
'DISPLAY_SCHEDULE_TAB': {
'name': _('Part Scheduling'),
'description': _('Display part scheduling information'),
'default': True,
'validator': bool,
},
}
class Meta:

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
@ -40,7 +42,8 @@ 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
@ -48,7 +51,7 @@ from InvenTree.helpers import str2bool, isNull, increment
from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import BuildStatus
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
class CategoryList(generics.ListCreateAPIView):
@ -430,6 +433,142 @@ 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, title, 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,
'title': title,
'label': label,
'url': url,
})
# 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,
_('Incoming Purchase Order'),
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,
_('Outgoing Sales Order'),
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,
_('Stock produced by Build Order'),
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,
_('Stock required for Build Order'),
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
@ -1734,6 +1873,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

@ -32,6 +32,21 @@
</div>
</div>
{% settings_value 'DISPLAY_SCHEDULE_TAB' user=request.user as show_scheduling %}
{% if show_scheduling %}
<div class='panel panel-hidden' id='panel-scheduling'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Scheduling" %}</h4>
{% include "spacer.html" %}
</div>
</div>
<div class='panel-content'>
{% include "part/part_scheduling.html" %}
</div>
</div>
{% endif %}
<div class='panel panel-hidden' id='panel-allocations'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@ -417,6 +432,11 @@
{% block js_ready %}
{{ block.super }}
// Load the "scheduling" tab
onPanelLoad('scheduling', function() {
loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
});
// Load the "suppliers" tab
onPanelLoad('suppliers', function() {

View File

@ -0,0 +1,6 @@
{% load i18n %}
{% load inventree_extras %}
<div id='part-schedule' style='max-height: 300px;'>
<canvas id='part-schedule-chart' width='100%' style='max-height: 300px;'></canvas>
</div>

View File

@ -44,6 +44,11 @@
{% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label="sales-orders" text=text icon="fa-truck" %}
{% endif %}
{% settings_value 'DISPLAY_SCHEDULE_TAB' user=request.user as show_scheduling %}
{% if show_scheduling %}
{% trans "Scheduling" as text %}
{% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %}
{% endif %}
{% if part.trackable %}
{% trans "Test Templates" as text %}
{% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %}

View File

@ -18,6 +18,7 @@
{% include "InvenTree/settings/setting.html" with key="DATE_DISPLAY_FORMAT" icon="fa-calendar-alt" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="DISPLAY_SCHEDULE_TAB" icon="fa-calendar-alt" user_setting=True %}
</tbody>
</table>
</div>

View File

@ -153,8 +153,10 @@
<script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script>
<script type="text/javascript" src="{% static 'fullcalendar/locales-all.js' %}"></script>
<script type="text/javascript" src="{% static 'select2/js/select2.full.js' %}"></script>
<script type='text/javascript' src="{% static 'script/chart.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
<script type='text/javascript' src="{% static 'script/chart.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/chartjs-adapter-moment.js' %}"></script>
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>

View File

@ -33,6 +33,7 @@
loadPartPurchaseOrderTable,
loadPartTable,
loadPartTestTemplateTable,
loadPartSchedulingChart,
loadPartVariantTable,
loadRelatedPartsTable,
loadSellPricingChart,
@ -1981,6 +1982,123 @@ function initPriceBreakSet(table, options) {
}
function loadPartSchedulingChart(canvas_id, part_id) {
var part_info = null;
// First, grab updated data for the particular part
inventreeGet(`/api/part/${part_id}/`, {}, {
async: false,
success: function(response) {
part_info = response;
}
});
var today = moment();
// Create an initial entry, using the available quantity
var stock_schedule = [
{
date: today,
delta: 0,
label: '{% trans "Current Stock" %}',
}
];
/* 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,
title: entry.title,
label: entry.label,
url: entry.url,
});
});
}
}
);
// Iterate through future "events" to calculate expected quantity
var quantity = part_info.in_stock;
for (var idx = 0; idx < stock_schedule.length; idx++) {
quantity += stock_schedule[idx].delta;
stock_schedule[idx].x = stock_schedule[idx].date.format('YYYY-MM-DD');
stock_schedule[idx].y = quantity;
}
var context = document.getElementById(canvas_id);
const data = {
datasets: [{
label: '{% trans "Scheduled Stock Quantities" %}',
data: stock_schedule,
backgroundColor: 'rgb(220, 160, 80)',
borderWidth: 2,
borderColor: 'rgb(90, 130, 150)'
}],
};
return new Chart(context, {
type: 'scatter',
data: data,
options: {
showLine: true,
stepped: true,
scales: {
x: {
type: 'time',
min: today.format(),
position: 'bottom',
time: {
unit: 'day',
},
},
y: {
beginAtZero: true,
}
},
plugins: {
tooltip: {
callbacks: {
label: function(item) {
return item.raw.label;
},
beforeLabel: function(item) {
return item.raw.title;
},
afterLabel: function(item) {
var delta = item.raw.delta;
if (delta == 0) {
delta = '';
} else {
delta = ` (${item.raw.delta > 0 ? '+' : ''}${item.raw.delta})`;
}
return `{% trans "Quantity" %}: ${item.raw.y}${delta}`;
}
}
}
},
}
});
}
function loadStockPricingChart(context, data) {
return new Chart(context, {
type: 'bar',