mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2695 from SchrodingersGat/scheduling
[WIP] Scheduling
This commit is contained in:
commit
fda556c289
13
InvenTree/InvenTree/static/script/chart.js
Normal file
13
InvenTree/InvenTree/static/script/chart.js
Normal file
File diff suppressed because one or more lines are too long
13
InvenTree/InvenTree/static/script/chart.min.js
vendored
13
InvenTree/InvenTree/static/script/chart.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -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() {
|
||||
|
||||
|
6
InvenTree/part/templates/part/part_scheduling.html
Normal file
6
InvenTree/part/templates/part/part_scheduling.html
Normal 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>
|
@ -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" %}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user