mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into matmair/issue2279
This commit is contained in:
commit
4100834ce9
@ -43,14 +43,12 @@ class Command(BaseCommand):
|
||||
try:
|
||||
model.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"ERROR: Image file '{img}' is missing")
|
||||
logger.warning(f"Warning: Image file '{img}' is missing")
|
||||
except UnidentifiedImageError:
|
||||
logger.error(f"ERROR: Image file '{img}' is not a valid image")
|
||||
logger.warning(f"Warning: Image file '{img}' is not a valid image")
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
logger.info("Rebuilding Part thumbnails")
|
||||
|
||||
for part in Part.objects.exclude(image=None):
|
||||
|
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
@ -12,11 +12,18 @@ import common.models
|
||||
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 28
|
||||
INVENTREE_API_VERSION = 30
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v30 -> 2022-03-09
|
||||
- Adds "exclude_location" field to BuildAutoAllocation API endpoint
|
||||
- Allows BuildItem API endpoint to be filtered by BomItem relation
|
||||
|
||||
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
|
||||
|
@ -461,6 +461,7 @@ class BuildItemList(generics.ListCreateAPIView):
|
||||
filter_fields = [
|
||||
'build',
|
||||
'stock_item',
|
||||
'bom_item',
|
||||
'install_into',
|
||||
]
|
||||
|
||||
|
@ -715,7 +715,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
build=self,
|
||||
bom_item=bom_item,
|
||||
stock_item=stock_item,
|
||||
quantity=quantity,
|
||||
quantity=1,
|
||||
install_into=output,
|
||||
)
|
||||
|
||||
@ -842,6 +842,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
"""
|
||||
|
||||
location = kwargs.get('location', None)
|
||||
exclude_location = kwargs.get('exclude_location', None)
|
||||
interchangeable = kwargs.get('interchangeable', False)
|
||||
substitutes = kwargs.get('substitutes', True)
|
||||
|
||||
@ -875,6 +876,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
sublocations = location.get_descendants(include_self=True)
|
||||
available_stock = available_stock.filter(location__in=[loc for loc in sublocations])
|
||||
|
||||
if exclude_location:
|
||||
# Exclude any stock items from the provided location
|
||||
sublocations = exclude_location.get_descendants(include_self=True)
|
||||
available_stock = available_stock.exclude(location__in=[loc for loc in sublocations])
|
||||
|
||||
"""
|
||||
Next, we sort the available stock items with the following priority:
|
||||
1. Direct part matches (+1)
|
||||
|
@ -717,6 +717,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||
class Meta:
|
||||
fields = [
|
||||
'location',
|
||||
'exclude_location',
|
||||
'interchangeable',
|
||||
'substitutes',
|
||||
]
|
||||
@ -730,6 +731,15 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||
help_text=_('Stock location where parts are to be sourced (leave blank to take from any location)'),
|
||||
)
|
||||
|
||||
exclude_location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('Exclude Location'),
|
||||
help_text=_('Exclude stock items from this selected location'),
|
||||
)
|
||||
|
||||
interchangeable = serializers.BooleanField(
|
||||
default=False,
|
||||
label=_('Interchangeable Stock'),
|
||||
@ -750,6 +760,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||
|
||||
build.auto_allocate_stock(
|
||||
location=data.get('location', None),
|
||||
exclude_location=data.get('exclude_location', None),
|
||||
interchangeable=data['interchangeable'],
|
||||
substitutes=data['substitutes'],
|
||||
)
|
||||
|
@ -323,17 +323,8 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('#btn-create-output').click(function() {
|
||||
|
||||
createBuildOutput(
|
||||
{{ build.pk }},
|
||||
{
|
||||
trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
loadStockTable($("#build-stock-table"), {
|
||||
onPanelLoad('completed', function() {
|
||||
loadStockTable($("#build-stock-table"), {
|
||||
params: {
|
||||
location_detail: true,
|
||||
part_detail: true,
|
||||
@ -345,8 +336,65 @@ loadStockTable($("#build-stock-table"), {
|
||||
'#stock-options',
|
||||
],
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
});
|
||||
|
||||
onPanelLoad('children', function() {
|
||||
loadBuildTable($('#sub-build-table'), {
|
||||
url: '{% url "api-build-list" %}',
|
||||
filterTarget: "#filter-list-sub-build",
|
||||
params: {
|
||||
ancestor: {{ build.pk }},
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onPanelLoad('attachments', function() {
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-build-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
build: {{ build.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
|
||||
filters: {
|
||||
build: {{ build.pk }},
|
||||
},
|
||||
fields: {
|
||||
build: {
|
||||
value: {{ build.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onPanelLoad('notes', function() {
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-build-detail" build.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function reloadTable() {
|
||||
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||
}
|
||||
|
||||
// Get the list of BOM items required for this build
|
||||
inventreeGet(
|
||||
@ -436,57 +484,16 @@ inventreeGet(
|
||||
}
|
||||
);
|
||||
|
||||
loadBuildTable($('#sub-build-table'), {
|
||||
url: '{% url "api-build-list" %}',
|
||||
filterTarget: "#filter-list-sub-build",
|
||||
params: {
|
||||
ancestor: {{ build.pk }},
|
||||
}
|
||||
});
|
||||
$('#btn-create-output').click(function() {
|
||||
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-build-attachment-list" %}',
|
||||
createBuildOutput(
|
||||
{{ build.pk }},
|
||||
{
|
||||
data: {
|
||||
build: {{ build.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
|
||||
filters: {
|
||||
build: {{ build.pk }},
|
||||
},
|
||||
fields: {
|
||||
build: {
|
||||
value: {{ build.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-build-detail" build.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
function reloadTable() {
|
||||
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||
}
|
||||
|
||||
{% if build.active %}
|
||||
|
||||
$("#btn-auto-allocate").on('click', function() {
|
||||
@ -578,5 +585,4 @@ $("#btn-order-parts").click(function() {
|
||||
|
||||
enableSidebar('buildorder');
|
||||
|
||||
|
||||
{% endblock %}
|
@ -9,10 +9,10 @@
|
||||
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
|
||||
{% endif %}
|
||||
{% if not build.is_complete %}
|
||||
{% trans "Pending Items" as text %}
|
||||
{% trans "Incomplete Outputs" as text %}
|
||||
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
|
||||
{% endif %}
|
||||
{% trans "Completed Items" as text %}
|
||||
{% trans "Completed Outputs" as text %}
|
||||
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
|
||||
{% trans "Child Build Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
|
||||
|
@ -23,7 +23,7 @@ class CommonConfig(AppConfig):
|
||||
try:
|
||||
import common.models
|
||||
|
||||
if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED'):
|
||||
if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED', backup_value=False, create=False):
|
||||
logger.info("Clearing SERVER_RESTART_REQUIRED flag")
|
||||
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)
|
||||
except:
|
||||
|
@ -273,6 +273,10 @@ class BaseInvenTreeSetting(models.Model):
|
||||
# Setting does not exist! (Try to create it)
|
||||
if not setting:
|
||||
|
||||
# Unless otherwise specified, attempt to create the setting
|
||||
create = kwargs.get('create', True)
|
||||
|
||||
if create:
|
||||
# Attempt to create a new settings object
|
||||
setting = cls(
|
||||
key=key,
|
||||
@ -1258,7 +1262,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:
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -50,7 +50,7 @@
|
||||
</div>
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
<button type='button' class='btn btn-outline-secondary' id='place-order' title='{% trans "Place order" %}'>
|
||||
<span class='fas fa-shopping-cart icon-blue'></span>
|
||||
<span class='fas fa-paper-plane icon-blue'></span>
|
||||
</button>
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-primary' id='receive-order' title='{% trans "Receive items" %}'>
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -20,7 +20,7 @@ from django.db.models.functions import Coalesce
|
||||
from django.core.validators import MinValueValidator
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from jinja2 import Template
|
||||
@ -76,6 +76,35 @@ class PartCategory(InvenTreeTree):
|
||||
default_keywords: Default keywords for parts created in this category
|
||||
"""
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
Custom model deletion routine, which updates any child categories or parts.
|
||||
This must be handled within a transaction.atomic(), otherwise the tree structure is damaged
|
||||
"""
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
parent = self.parent
|
||||
tree_id = self.tree_id
|
||||
|
||||
# Update each part in this category to point to the parent category
|
||||
for part in self.parts.all():
|
||||
part.category = self.parent
|
||||
part.save()
|
||||
|
||||
# Update each child category
|
||||
for child in self.children.all():
|
||||
child.parent = self.parent
|
||||
child.save()
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
if parent is not None:
|
||||
# Partially rebuild the tree (cheaper than a complete rebuild)
|
||||
PartCategory.objects.partial_rebuild(tree_id)
|
||||
else:
|
||||
PartCategory.objects.rebuild()
|
||||
|
||||
default_location = TreeForeignKey(
|
||||
'stock.StockLocation', related_name="default_categories",
|
||||
null=True, blank=True,
|
||||
@ -260,27 +289,6 @@ class PartCategory(InvenTreeTree):
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
|
||||
def before_delete_part_category(sender, instance, using, **kwargs):
|
||||
""" Receives before_delete signal for PartCategory object
|
||||
|
||||
Before deleting, update child Part and PartCategory objects:
|
||||
|
||||
- For each child category, set the parent to the parent of *this* category
|
||||
- For each part, set the 'category' to the parent of *this* category
|
||||
"""
|
||||
|
||||
# Update each part in this category to point to the parent category
|
||||
for part in instance.parts.all():
|
||||
part.category = instance.parent
|
||||
part.save()
|
||||
|
||||
# Update each child category
|
||||
for child in instance.children.all():
|
||||
child.parent = instance.parent
|
||||
child.save()
|
||||
|
||||
|
||||
def rename_part_image(instance, filename):
|
||||
""" Function for renaming a part image file
|
||||
|
||||
|
@ -2,38 +2,31 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
{% trans 'Are you sure you want to delete category' %} <strong>{{ category.name }}</strong>?
|
||||
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "Are you sure you want to delete this part category?" %}
|
||||
</div>
|
||||
|
||||
{% if category.children.all|length > 0 %}
|
||||
<p>{% blocktrans with count=category.children.all|length%}This category contains {{count}} child categories{% endblocktrans %}.<br>
|
||||
{% trans 'If this category is deleted, these child categories will be moved to the' %}
|
||||
{% if category.parent %}
|
||||
<strong>{{ category.parent.name }}</strong> {% trans 'category' %}.
|
||||
{% else %}
|
||||
{% trans 'top level Parts category' %}.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<ul class='list-group'>
|
||||
{% for cat in category.children.all %}
|
||||
<li class='list-group-item'><strong>{{ cat.name }}</strong> - <em>{{ cat.description }}</em></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% blocktrans with n=category.children.all|length %}This category contains {{ n }} child categories{% endblocktrans %}.<br>
|
||||
{% if category.parent %}
|
||||
{% blocktrans with category=category.parent.name %}If this category is deleted, these child categories will be moved to {{ category }}{% endblocktrans %}.
|
||||
{% else %}
|
||||
{% trans "If this category is deleted, these child categories will be moved to the top level part category" %}.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if category.parts.all|length > 0 %}
|
||||
<p>{% blocktrans with count=category.parts.all|length %}This category contains {{count}} parts{% endblocktrans %}.<br>
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% blocktrans with n=category.parts.all|length %}This category contains {{ n }} parts{% endblocktrans %}.<br>
|
||||
{% if category.parent %}
|
||||
{% blocktrans with path=category.parent.pathstring %}If this category is deleted, these parts will be moved to the parent category {{path}}{% endblocktrans %}
|
||||
{% blocktrans with category=category.parent.name %}If this category is deleted, these parts will be moved to {{ category }}{% endblocktrans %}.
|
||||
{% else %}
|
||||
{% trans 'If this category is deleted, these parts will be moved to the top-level category Teile' %}
|
||||
{% trans "If this category is deleted, these parts will be moved to the top level part category" %}.
|
||||
{% endif %}
|
||||
</p>
|
||||
<ul class='list-group'>
|
||||
{% for part in category.parts.all %}
|
||||
<li class='list-group-item'><strong>{{ part.full_name }}</strong> - <em>{{ part.description }}</em></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -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" %}
|
||||
|
@ -172,3 +172,122 @@ class CategoryTest(TestCase):
|
||||
# And one part should have no default location at all
|
||||
w = Part.objects.get(name='Widget')
|
||||
self.assertIsNone(w.get_default_location())
|
||||
|
||||
def test_category_tree(self):
|
||||
"""
|
||||
Unit tests for the part category tree structure (MPTT)
|
||||
Ensure that the MPTT structure is rebuilt correctly,
|
||||
and the correct ancestor tree is observed.
|
||||
"""
|
||||
|
||||
# Clear out any existing parts
|
||||
Part.objects.all().delete()
|
||||
|
||||
# First, create a structured tree of part categories
|
||||
A = PartCategory.objects.create(
|
||||
name='A',
|
||||
description='Top level category',
|
||||
)
|
||||
|
||||
B1 = PartCategory.objects.create(name='B1', parent=A)
|
||||
B2 = PartCategory.objects.create(name='B2', parent=A)
|
||||
B3 = PartCategory.objects.create(name='B3', parent=A)
|
||||
|
||||
C11 = PartCategory.objects.create(name='C11', parent=B1)
|
||||
C12 = PartCategory.objects.create(name='C12', parent=B1)
|
||||
C13 = PartCategory.objects.create(name='C13', parent=B1)
|
||||
|
||||
C21 = PartCategory.objects.create(name='C21', parent=B2)
|
||||
C22 = PartCategory.objects.create(name='C22', parent=B2)
|
||||
C23 = PartCategory.objects.create(name='C23', parent=B2)
|
||||
|
||||
C31 = PartCategory.objects.create(name='C31', parent=B3)
|
||||
C32 = PartCategory.objects.create(name='C32', parent=B3)
|
||||
C33 = PartCategory.objects.create(name='C33', parent=B3)
|
||||
|
||||
# Check that the tree_id value is correct
|
||||
for cat in [B1, B2, B3, C11, C22, C33]:
|
||||
self.assertEqual(cat.tree_id, A.tree_id)
|
||||
self.assertEqual(cat.level, cat.parent.level + 1)
|
||||
self.assertEqual(cat.get_ancestors().count(), cat.level)
|
||||
|
||||
# Spot check for C31
|
||||
ancestors = C31.get_ancestors(include_self=True)
|
||||
|
||||
self.assertEqual(ancestors.count(), 3)
|
||||
self.assertEqual(ancestors[0], A)
|
||||
self.assertEqual(ancestors[1], B3)
|
||||
self.assertEqual(ancestors[2], C31)
|
||||
|
||||
# At this point, we are confident that the tree is correctly structured
|
||||
|
||||
# Add some parts to category B3
|
||||
|
||||
for i in range(10):
|
||||
Part.objects.create(
|
||||
name=f'Part {i}',
|
||||
description='A test part',
|
||||
category=B3,
|
||||
)
|
||||
|
||||
self.assertEqual(Part.objects.filter(category=B3).count(), 10)
|
||||
self.assertEqual(Part.objects.filter(category=A).count(), 0)
|
||||
|
||||
# Delete category B3
|
||||
B3.delete()
|
||||
|
||||
# Child parts have been moved to category A
|
||||
self.assertEqual(Part.objects.filter(category=A).count(), 10)
|
||||
|
||||
for cat in [C31, C32, C33]:
|
||||
# These categories should now be directly under A
|
||||
cat.refresh_from_db()
|
||||
|
||||
self.assertEqual(cat.parent, A)
|
||||
self.assertEqual(cat.level, 1)
|
||||
self.assertEqual(cat.get_ancestors().count(), 1)
|
||||
self.assertEqual(cat.get_ancestors()[0], A)
|
||||
|
||||
# Now, delete category A
|
||||
A.delete()
|
||||
|
||||
# Parts have now been moved to the top-level category
|
||||
self.assertEqual(Part.objects.filter(category=None).count(), 10)
|
||||
|
||||
for loc in [B1, B2, C31, C32, C33]:
|
||||
# These should now all be "top level" categories
|
||||
loc.refresh_from_db()
|
||||
|
||||
self.assertEqual(loc.level, 0)
|
||||
self.assertEqual(loc.parent, None)
|
||||
|
||||
# Check descendants for B1
|
||||
descendants = B1.get_descendants()
|
||||
self.assertEqual(descendants.count(), 3)
|
||||
|
||||
for loc in [C11, C12, C13]:
|
||||
self.assertTrue(loc in descendants)
|
||||
|
||||
# Check category C1x, should be B1 -> C1x
|
||||
for loc in [C11, C12, C13]:
|
||||
loc.refresh_from_db()
|
||||
|
||||
self.assertEqual(loc.level, 1)
|
||||
self.assertEqual(loc.parent, B1)
|
||||
ancestors = loc.get_ancestors(include_self=True)
|
||||
|
||||
self.assertEqual(ancestors.count(), 2)
|
||||
self.assertEqual(ancestors[0], B1)
|
||||
self.assertEqual(ancestors[1], loc)
|
||||
|
||||
# Check category C2x, should be B2 -> C2x
|
||||
for loc in [C21, C22, C23]:
|
||||
loc.refresh_from_db()
|
||||
|
||||
self.assertEqual(loc.level, 1)
|
||||
self.assertEqual(loc.parent, B2)
|
||||
ancestors = loc.get_ancestors(include_self=True)
|
||||
|
||||
self.assertEqual(ancestors.count(), 2)
|
||||
self.assertEqual(ancestors[0], B2)
|
||||
self.assertEqual(ancestors[1], loc)
|
||||
|
@ -54,6 +54,35 @@ class StockLocation(InvenTreeTree):
|
||||
Stock locations can be heirarchical as required
|
||||
"""
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
Custom model deletion routine, which updates any child locations or items.
|
||||
This must be handled within a transaction.atomic(), otherwise the tree structure is damaged
|
||||
"""
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
parent = self.parent
|
||||
tree_id = self.tree_id
|
||||
|
||||
# Update each stock item in the stock location
|
||||
for item in self.stock_items.all():
|
||||
item.location = self.parent
|
||||
item.save()
|
||||
|
||||
# Update each child category
|
||||
for child in self.children.all():
|
||||
child.parent = self.parent
|
||||
child.save()
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
if parent is not None:
|
||||
# Partially rebuild the tree (cheaper than a complete rebuild)
|
||||
StockLocation.objects.partial_rebuild(tree_id)
|
||||
else:
|
||||
StockLocation.objects.rebuild()
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
return reverse('api-location-list')
|
||||
@ -159,20 +188,6 @@ class StockLocation(InvenTreeTree):
|
||||
return self.stock_item_count()
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=StockLocation, dispatch_uid='stocklocation_delete_log')
|
||||
def before_delete_stock_location(sender, instance, using, **kwargs):
|
||||
|
||||
# Update each part in the stock location
|
||||
for item in instance.stock_items.all():
|
||||
item.location = instance.parent
|
||||
item.save()
|
||||
|
||||
# Update each child category
|
||||
for child in instance.children.all():
|
||||
child.parent = instance.parent
|
||||
child.save()
|
||||
|
||||
|
||||
class StockItemManager(TreeManager):
|
||||
"""
|
||||
Custom database manager for the StockItem class.
|
||||
|
@ -462,9 +462,7 @@ $("#print-label").click(function() {
|
||||
{% if roles.stock.change %}
|
||||
$("#stock-duplicate").click(function() {
|
||||
// Duplicate a stock item
|
||||
duplicateStockItem({{ item.pk }}, {
|
||||
follow: true,
|
||||
});
|
||||
duplicateStockItem({{ item.pk }}, {});
|
||||
});
|
||||
|
||||
$('#stock-edit').click(function() {
|
||||
|
@ -4,40 +4,31 @@
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
{% trans "Are you sure you want to delete this stock location?" %}
|
||||
|
||||
<br>
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "Are you sure you want to delete this stock location?" %}
|
||||
</div>
|
||||
|
||||
{% if location.children.all|length > 0 %}
|
||||
<p>This location contains {{ location.children.all|length }} child locations.<br>
|
||||
If this location is deleted, these child locations will be moved to
|
||||
{% if location.parent %}
|
||||
the '{{ location.parent.name }}' location.
|
||||
{% else %}
|
||||
the top level 'Stock' location.
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% blocktrans with n=location.children.all|length %}This location contains {{ n }} child locations{% endblocktrans %}.<br>
|
||||
{% if location.parent %}
|
||||
{% blocktrans with location=location.parent.name %}If this location is deleted, these child locations will be moved to {{ location }}{% endblocktrans %}.
|
||||
{% else %}
|
||||
{% trans "If this location is deleted, these child locations will be moved to the top level stock location" %}.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<ul class='list-group'>
|
||||
{% for loc in location.children.all %}
|
||||
<li class='list-group-item'><strong>{{ loc.name }}</strong> - <em>{{ loc.description}}</em></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if location.stock_items.all|length > 0 %}
|
||||
<p>This location contains {{ location.stock_items.all|length }} stock items.<br>
|
||||
{% if location.parent %}
|
||||
If this location is deleted, these items will be moved to the '{{ location.parent.name }}' location.
|
||||
{% else %}
|
||||
If this location is deleted, these items will be moved to the top level 'Stock' location.
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% blocktrans with n=location.stock_items.all|length %}This location contains {{ n }} stock items{% endblocktrans %}.<br>
|
||||
{% if location.parent %}
|
||||
{% blocktrans with location=location.parent.name %}If this location is deleted, these stock items will be moved to {{ location }}{% endblocktrans %}.
|
||||
{% else %}
|
||||
{% trans "If this location is deleted, these stock items will be moved to the top level stock location" %}.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<ul class='list-group'>
|
||||
{% for item in location.stock_items.all %}
|
||||
<li class='list-group-item'><strong>{{ item.part.full_name }}</strong> - <em>{{ item.part.description }}</em><span class='badge badge-right rounded-pill bg-dark'>{% decimal item.quantity %}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -524,6 +524,174 @@ class StockTest(TestCase):
|
||||
# Serialize the remainder of the stock
|
||||
item.serializeStock(2, [99, 100], self.user)
|
||||
|
||||
def test_location_tree(self):
|
||||
"""
|
||||
Unit tests for stock location tree structure (MPTT).
|
||||
Ensure that the MPTT structure is rebuilt correctly,
|
||||
and the corrent ancestor tree is observed.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/issues/2636
|
||||
Ref: https://github.com/inventree/InvenTree/issues/2733
|
||||
"""
|
||||
|
||||
# First, we will create a stock location structure
|
||||
|
||||
A = StockLocation.objects.create(
|
||||
name='A',
|
||||
description='Top level location'
|
||||
)
|
||||
|
||||
B1 = StockLocation.objects.create(
|
||||
name='B1',
|
||||
parent=A
|
||||
)
|
||||
|
||||
B2 = StockLocation.objects.create(
|
||||
name='B2',
|
||||
parent=A
|
||||
)
|
||||
|
||||
B3 = StockLocation.objects.create(
|
||||
name='B3',
|
||||
parent=A
|
||||
)
|
||||
|
||||
C11 = StockLocation.objects.create(
|
||||
name='C11',
|
||||
parent=B1,
|
||||
)
|
||||
|
||||
C12 = StockLocation.objects.create(
|
||||
name='C12',
|
||||
parent=B1,
|
||||
)
|
||||
|
||||
C21 = StockLocation.objects.create(
|
||||
name='C21',
|
||||
parent=B2,
|
||||
)
|
||||
|
||||
C22 = StockLocation.objects.create(
|
||||
name='C22',
|
||||
parent=B2,
|
||||
)
|
||||
|
||||
C31 = StockLocation.objects.create(
|
||||
name='C31',
|
||||
parent=B3,
|
||||
)
|
||||
|
||||
C32 = StockLocation.objects.create(
|
||||
name='C32',
|
||||
parent=B3
|
||||
)
|
||||
|
||||
# Check that the tree_id is correct for each sublocation
|
||||
for loc in [B1, B2, B3, C11, C12, C21, C22, C31, C32]:
|
||||
self.assertEqual(loc.tree_id, A.tree_id)
|
||||
|
||||
# Check that the tree levels are correct for each node in the tree
|
||||
|
||||
self.assertEqual(A.level, 0)
|
||||
self.assertEqual(A.get_ancestors().count(), 0)
|
||||
|
||||
for loc in [B1, B2, B3]:
|
||||
self.assertEqual(loc.parent, A)
|
||||
self.assertEqual(loc.level, 1)
|
||||
self.assertEqual(loc.get_ancestors().count(), 1)
|
||||
|
||||
for loc in [C11, C12]:
|
||||
self.assertEqual(loc.parent, B1)
|
||||
self.assertEqual(loc.level, 2)
|
||||
self.assertEqual(loc.get_ancestors().count(), 2)
|
||||
|
||||
for loc in [C21, C22]:
|
||||
self.assertEqual(loc.parent, B2)
|
||||
self.assertEqual(loc.level, 2)
|
||||
self.assertEqual(loc.get_ancestors().count(), 2)
|
||||
|
||||
for loc in [C31, C32]:
|
||||
self.assertEqual(loc.parent, B3)
|
||||
self.assertEqual(loc.level, 2)
|
||||
self.assertEqual(loc.get_ancestors().count(), 2)
|
||||
|
||||
# Spot-check for C32
|
||||
ancestors = C32.get_ancestors(include_self=True)
|
||||
|
||||
self.assertEqual(ancestors[0], A)
|
||||
self.assertEqual(ancestors[1], B3)
|
||||
self.assertEqual(ancestors[2], C32)
|
||||
|
||||
# At this point, we are confident that the tree is correctly structured.
|
||||
|
||||
# Let's delete node B3 from the tree. We expect that:
|
||||
# - C31 should move directly under A
|
||||
# - C32 should move directly under A
|
||||
|
||||
# Add some stock items to B3
|
||||
for i in range(10):
|
||||
StockItem.objects.create(
|
||||
part=Part.objects.get(pk=1),
|
||||
quantity=10,
|
||||
location=B3
|
||||
)
|
||||
|
||||
self.assertEqual(StockItem.objects.filter(location=B3).count(), 10)
|
||||
self.assertEqual(StockItem.objects.filter(location=A).count(), 0)
|
||||
|
||||
B3.delete()
|
||||
|
||||
A.refresh_from_db()
|
||||
C31.refresh_from_db()
|
||||
C32.refresh_from_db()
|
||||
|
||||
# Stock items have been moved to A
|
||||
self.assertEqual(StockItem.objects.filter(location=A).count(), 10)
|
||||
|
||||
# Parent should be A
|
||||
self.assertEqual(C31.parent, A)
|
||||
self.assertEqual(C32.parent, A)
|
||||
|
||||
self.assertEqual(C31.tree_id, A.tree_id)
|
||||
self.assertEqual(C31.level, 1)
|
||||
|
||||
self.assertEqual(C32.tree_id, A.tree_id)
|
||||
self.assertEqual(C32.level, 1)
|
||||
|
||||
# Ancestor tree should be just A
|
||||
ancestors = C31.get_ancestors()
|
||||
self.assertEqual(ancestors.count(), 1)
|
||||
self.assertEqual(ancestors[0], A)
|
||||
|
||||
ancestors = C32.get_ancestors()
|
||||
self.assertEqual(ancestors.count(), 1)
|
||||
self.assertEqual(ancestors[0], A)
|
||||
|
||||
# Delete A
|
||||
A.delete()
|
||||
|
||||
# Stock items have been moved to top-level location
|
||||
self.assertEqual(StockItem.objects.filter(location=None).count(), 10)
|
||||
|
||||
for loc in [B1, B2, C11, C12, C21, C22]:
|
||||
loc.refresh_from_db()
|
||||
|
||||
self.assertEqual(B1.parent, None)
|
||||
self.assertEqual(B2.parent, None)
|
||||
|
||||
self.assertEqual(C11.parent, B1)
|
||||
self.assertEqual(C12.parent, B1)
|
||||
self.assertEqual(C11.get_ancestors().count(), 1)
|
||||
self.assertEqual(C12.get_ancestors().count(), 1)
|
||||
|
||||
self.assertEqual(C21.parent, B2)
|
||||
self.assertEqual(C22.parent, B2)
|
||||
|
||||
ancestors = C21.get_ancestors()
|
||||
|
||||
self.assertEqual(C21.get_ancestors().count(), 1)
|
||||
self.assertEqual(C22.get_ancestors().count(), 1)
|
||||
|
||||
|
||||
class VariantTest(StockTest):
|
||||
"""
|
||||
|
@ -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>
|
||||
|
@ -154,8 +154,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>
|
||||
|
||||
|
@ -1219,6 +1219,18 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
$(table).bootstrapTable('updateByUniqueId', key, tableRow, true);
|
||||
}
|
||||
|
||||
// Update any rows which we did not receive allocation information for
|
||||
var td = $(table).bootstrapTable('getData');
|
||||
|
||||
td.forEach(function(tableRow) {
|
||||
if (tableRow.allocations == null) {
|
||||
|
||||
tableRow.allocations = [];
|
||||
|
||||
$(table).bootstrapTable('updateByUniqueId', tableRow.pk, tableRow, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Update the progress bar for this build output
|
||||
var build_progress = $(`#output-progress-${outputId}`);
|
||||
|
||||
@ -1419,15 +1431,17 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
formatter: function(value, row) {
|
||||
var allocated = 0;
|
||||
|
||||
if (row.allocations) {
|
||||
if (row.allocations != null) {
|
||||
row.allocations.forEach(function(item) {
|
||||
allocated += item.quantity;
|
||||
});
|
||||
}
|
||||
|
||||
var required = requiredQuantity(row);
|
||||
|
||||
return makeProgressBar(allocated, required);
|
||||
} else {
|
||||
return `<em>{% trans "loading" %}...</em><span class='fas fa-spinner fa-spin float-right'></span>`;
|
||||
}
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
// Custom sorting function for progress bars
|
||||
@ -1876,6 +1890,7 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
|
||||
location: {
|
||||
value: options.location,
|
||||
},
|
||||
exclude_location: {},
|
||||
interchangeable: {
|
||||
value: true,
|
||||
},
|
||||
|
@ -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',
|
||||
|
@ -294,7 +294,17 @@ function stockItemGroups(options={}) {
|
||||
*/
|
||||
function duplicateStockItem(pk, options) {
|
||||
|
||||
// First, we need the StockItem informatino
|
||||
// If no "success" function provided, add a default
|
||||
if (!options.onSuccess) {
|
||||
options.onSuccess = function(response) {
|
||||
|
||||
showAlertOrCache('{% trans "Stock item duplicated" %}', true, {style: 'success'});
|
||||
|
||||
window.location.href = `/stock/item/${response.pk}/`;
|
||||
};
|
||||
}
|
||||
|
||||
// First, we need the StockItem information
|
||||
inventreeGet(`/api/stock/${pk}/`, {}, {
|
||||
success: function(data) {
|
||||
|
||||
|
@ -35,7 +35,7 @@ importlib_metadata # Backport for importlib.metadata
|
||||
inventree # Install the latest version of the InvenTree API python library
|
||||
markdown==3.3.4 # Force particular version of markdown
|
||||
pep8-naming==0.11.1 # PEP naming convention extension
|
||||
pillow==9.0.0 # Image manipulation
|
||||
pillow==9.0.1 # Image manipulation
|
||||
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
|
||||
pygments==2.7.4 # Syntax highlighting
|
||||
python-barcode[images]==0.13.1 # Barcode generator
|
||||
|
Loading…
Reference in New Issue
Block a user