2
0
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:
Matthias Mair
2022-03-14 23:14:29 +01:00
committed by GitHub
51 changed files with 19267 additions and 31473 deletions

View File

@ -5,6 +5,8 @@ Provides a JSON API for the Part app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
from django.conf.urls import url, include
from django.http import JsonResponse
from django.db.models import Q, F, Count, Min, Max, Avg
@ -40,7 +42,8 @@ from company.models import Company, ManufacturerPart, SupplierPart
from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting
from build.models import Build
from build.models import Build, BuildItem
import order.models
from . import serializers as part_serializers
@ -48,7 +51,7 @@ from InvenTree.helpers import str2bool, isNull, increment
from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import BuildStatus
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
class CategoryList(generics.ListCreateAPIView):
@ -430,6 +433,142 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
]
class PartScheduling(generics.RetrieveAPIView):
"""
API endpoint for delivering "scheduling" information about a given part via the API.
Returns a chronologically ordered list about future "scheduled" events,
concerning stock levels for the part:
- Purchase Orders (incoming stock)
- Sales Orders (outgoing stock)
- Build Orders (incoming completed stock)
- Build Orders (outgoing allocated stock)
"""
queryset = Part.objects.all()
def retrieve(self, request, *args, **kwargs):
today = datetime.datetime.now().date()
part = self.get_object()
schedule = []
def add_schedule_entry(date, quantity, title, label, url):
"""
Check if a scheduled entry should be added:
- date must be non-null
- date cannot be in the "past"
- quantity must not be zero
"""
if date and date >= today and quantity != 0:
schedule.append({
'date': date,
'quantity': quantity,
'title': title,
'label': label,
'url': url,
})
# Add purchase order (incoming stock) information
po_lines = order.models.PurchaseOrderLineItem.objects.filter(
part__part=part,
order__status__in=PurchaseOrderStatus.OPEN,
)
for line in po_lines:
target_date = line.target_date or line.order.target_date
quantity = max(line.quantity - line.received, 0)
add_schedule_entry(
target_date,
quantity,
_('Incoming Purchase Order'),
str(line.order),
line.order.get_absolute_url()
)
# Add sales order (outgoing stock) information
so_lines = order.models.SalesOrderLineItem.objects.filter(
part=part,
order__status__in=SalesOrderStatus.OPEN,
)
for line in so_lines:
target_date = line.target_date or line.order.target_date
quantity = max(line.quantity - line.shipped, 0)
add_schedule_entry(
target_date,
-quantity,
_('Outgoing Sales Order'),
str(line.order),
line.order.get_absolute_url(),
)
# Add build orders (incoming stock) information
build_orders = Build.objects.filter(
part=part,
status__in=BuildStatus.ACTIVE_CODES
)
for build in build_orders:
quantity = max(build.quantity - build.completed, 0)
add_schedule_entry(
build.target_date,
quantity,
_('Stock produced by Build Order'),
str(build),
build.get_absolute_url(),
)
"""
Add build order allocation (outgoing stock) information.
Here we need some careful consideration:
- 'Tracked' stock items are removed from stock when the individual Build Output is completed
- 'Untracked' stock items are removed from stock when the Build Order is completed
The 'simplest' approach here is to look at existing BuildItem allocations which reference this part,
and "schedule" them for removal at the time of build order completion.
This assumes that the user is responsible for correctly allocating parts.
However, it has the added benefit of side-stepping the various BOM substition options,
and just looking at what stock items the user has actually allocated against the Build.
"""
build_allocations = BuildItem.objects.filter(
stock_item__part=part,
build__status__in=BuildStatus.ACTIVE_CODES,
)
for allocation in build_allocations:
add_schedule_entry(
allocation.build.target_date,
-allocation.quantity,
_('Stock required for Build Order'),
str(allocation.build),
allocation.build.get_absolute_url(),
)
# Sort by incrementing date values
schedule = sorted(schedule, key=lambda entry: entry['date'])
return Response(schedule)
class PartSerialNumberDetail(generics.RetrieveAPIView):
"""
API endpoint for returning extra serial number information about a particular part
@ -1734,6 +1873,9 @@ part_api_urls = [
# Endpoint for extra serial number information
url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
# Endpoint for future scheduling information
url(r'^scheduling/', PartScheduling.as_view(), name='api-part-scheduling'),
# Endpoint for duplicating a BOM for the specific Part
url(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'),

View File

@ -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

View 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 %}

View File

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

View File

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

View File

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

View File

@ -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)