mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-19 05:25:42 +00:00
Merge branch 'inventree:master' into matmair/issue2279
This commit is contained in:
@ -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)
|
||||
|
Reference in New Issue
Block a user