mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge pull request #2695 from SchrodingersGat/scheduling
[WIP] Scheduling
This commit is contained in:
		
							
								
								
									
										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', | ||||
|   | ||||
		Reference in New Issue
	
	Block a user