Merge pull request #653 from SchrodingersGat/stock-item-tree

Stock item tree
This commit is contained in:
Oliver 2020-02-19 00:02:59 +11:00 committed by GitHub
commit 6c415bc922
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 305 additions and 24 deletions

View File

@ -97,6 +97,10 @@ class Build(models.Model):
notes = MarkdownxField(blank=True, help_text=_('Extra build notes')) notes = MarkdownxField(blank=True, help_text=_('Extra build notes'))
@property
def output_count(self):
return self.build_outputs.count()
@transaction.atomic @transaction.atomic
def cancelBuild(self, user): def cancelBuild(self, user):
""" Mark the Build as CANCELLED """ Mark the Build as CANCELLED
@ -235,7 +239,7 @@ class Build(models.Model):
now=str(datetime.now().date()) now=str(datetime.now().date())
) )
if self.part.trackable: if self.part.trackable and serial_numbers:
# Add new serial numbers # Add new serial numbers
for serial in serial_numbers: for serial in serial_numbers:
item = StockItem.objects.create( item = StockItem.objects.create(

View File

@ -90,6 +90,10 @@ InvenTree | Build - {{ build }}
{% endblock %} {% endblock %}
{% block js_load %}
<script type='text/javascript' src="{% static 'script/inventree/stock.js' %}"></script>
{% endblock %}
{% block js_ready %} {% block js_ready %}
$("#build-edit").click(function () { $("#build-edit").click(function () {

View File

@ -0,0 +1,32 @@
{% extends "build/build_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include "build/tabs.html" with tab='output' %}
<h4>{% trans "Build Outputs" %}</h4>
<hr>
{% include "stock_table.html" %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadStockTable($("#stock-table"), {
params: {
location_detail: true,
part_details: true,
build: {{ build.id }},
},
groupByField: 'location',
buttons: [
'#stock-options',
],
url: "{% url 'api-stock-list' %}",
});
{% endblock %}

View File

@ -4,6 +4,9 @@
<li{% if tab == 'details' %} class='active'{% endif %}> <li{% if tab == 'details' %} class='active'{% endif %}>
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a> <a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
</li> </li>
<li{% if tab == 'output' %} class='active'{% endif %}>
<a href="{% url 'build-output' build.id %}">{% trans "Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
</li>
<li{% if tab == 'notes' %} class='active'{% endif %}> <li{% if tab == 'notes' %} class='active'{% endif %}>
<a href="{% url 'build-notes' build.id %}">{% trans "Notes" %}{% if build.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a> <a href="{% url 'build-notes' build.id %}">{% trans "Notes" %}{% if build.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
</li> </li>

View File

@ -26,6 +26,9 @@ build_detail_urls = [
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'), url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'), url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
] ]

View File

@ -85,6 +85,13 @@ class StockItemResource(ModelResource):
stocktake_date = Field(attribute='stocktake_date', widget=widgets.DateWidget()) stocktake_date = Field(attribute='stocktake_date', widget=widgets.DateWidget())
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the StockItem tree(s)
StockItem.objects.rebuild()
class Meta: class Meta:
model = StockItem model = StockItem
skip_unchanged = True skip_unchanged = True

View File

@ -257,8 +257,12 @@ class StockList(generics.ListCreateAPIView):
- location: Filter stock by location - location: Filter stock by location
- category: Filter by parts belonging to a certain category - category: Filter by parts belonging to a certain category
- supplier: Filter by supplier - supplier: Filter by supplier
- ancestor: Filter by an 'ancestor' StockItem
- status: Filter by the StockItem status
""" """
queryset = StockItem.objects.all()
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
try: try:
@ -284,6 +288,7 @@ class StockList(generics.ListCreateAPIView):
data = queryset.values( data = queryset.values(
'pk', 'pk',
'parent',
'quantity', 'quantity',
'serial', 'serial',
'batch', 'batch',
@ -332,7 +337,9 @@ class StockList(generics.ListCreateAPIView):
""" """
# Start with all objects # Start with all objects
stock_list = StockItem.objects.filter(customer=None, belongs_to=None) stock_list = super(StockList, self).get_queryset()
stock_list = stock_list.filter(customer=None, belongs_to=None)
# Does the client wish to filter by the Part ID? # Does the client wish to filter by the Part ID?
part_id = self.request.query_params.get('part', None) part_id = self.request.query_params.get('part', None)
@ -347,7 +354,20 @@ class StockList(generics.ListCreateAPIView):
else: else:
stock_list = stock_list.filter(part=part_id) stock_list = stock_list.filter(part=part_id)
except Part.DoesNotExist: except (ValueError, Part.DoesNotExist):
pass
# Does the client wish to filter by the 'ancestor'?
anc_id = self.request.query_params.get('ancestor', None)
if anc_id:
try:
ancestor = StockItem.objects.get(pk=anc_id)
# Only allow items which are descendants of the specified StockItem
stock_list = stock_list.filter(id__in=[item.pk for item in ancestor.children.all()])
except (ValueError, Part.DoesNotExist):
pass pass
# Does the client wish to filter by stock location? # Does the client wish to filter by stock location?
@ -358,7 +378,7 @@ class StockList(generics.ListCreateAPIView):
location = StockLocation.objects.get(pk=loc_id) location = StockLocation.objects.get(pk=loc_id)
stock_list = stock_list.filter(location__in=location.getUniqueChildren()) stock_list = stock_list.filter(location__in=location.getUniqueChildren())
except StockLocation.DoesNotExist: except (ValueError, StockLocation.DoesNotExist):
pass pass
# Does the client wish to filter by part category? # Does the client wish to filter by part category?
@ -369,9 +389,15 @@ class StockList(generics.ListCreateAPIView):
category = PartCategory.objects.get(pk=cat_id) category = PartCategory.objects.get(pk=cat_id)
stock_list = stock_list.filter(part__category__in=category.getUniqueChildren()) stock_list = stock_list.filter(part__category__in=category.getUniqueChildren())
except PartCategory.DoesNotExist: except (ValueError, PartCategory.DoesNotExist):
pass pass
# Filter by StockItem status
status = self.request.query_params.get('status', None)
if status:
stock_list = stock_list.filter(status=status)
# Filter by supplier_part ID # Filter by supplier_part ID
supplier_part_id = self.request.query_params.get('supplier_part', None) supplier_part_id = self.request.query_params.get('supplier_part', None)
@ -411,7 +437,7 @@ class StockList(generics.ListCreateAPIView):
'supplier_part', 'supplier_part',
'customer', 'customer',
'belongs_to', 'belongs_to',
# 'status' TODO - There are some issues filtering based on an enumeration field 'build'
] ]

View File

@ -7,6 +7,10 @@
location: 3 location: 3
batch: 'B123' batch: 'B123'
quantity: 4000 quantity: 4000
level: 0
tree_id: 0
lft: 0
rght: 0
# 5,000 screws in the bathroom # 5,000 screws in the bathroom
- model: stock.stockitem - model: stock.stockitem
@ -14,6 +18,10 @@
part: 1 part: 1
location: 2 location: 2
quantity: 5000 quantity: 5000
level: 0
tree_id: 0
lft: 0
rght: 0
# 1234 2K2 resistors in 'Drawer_1' # 1234 2K2 resistors in 'Drawer_1'
- model: stock.stockitem - model: stock.stockitem
@ -22,6 +30,10 @@
part: 3 part: 3
location: 5 location: 5
quantity: 1234 quantity: 1234
level: 0
tree_id: 0
lft: 0
rght: 0
# Some widgets in drawer 3 # Some widgets in drawer 3
- model: stock.stockitem - model: stock.stockitem
@ -31,6 +43,10 @@
location: 7 location: 7
quantity: 10 quantity: 10
delete_on_deplete: False delete_on_deplete: False
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 101 pk: 101
@ -38,6 +54,10 @@
part: 25 part: 25
location: 7 location: 7
quantity: 5 quantity: 5
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 102 pk: 102
@ -45,3 +65,7 @@
part: 25 part: 25
location: 7 location: 7
quantity: 3 quantity: 3
level: 0
tree_id: 0
lft: 0
rght: 0

View File

@ -0,0 +1,44 @@
# Generated by Django 2.2.9 on 2020-02-15 22:32
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('stock', '0020_auto_20200206_1213'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='stockitem',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='stockitem',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='stock.StockItem'),
),
migrations.AddField(
model_name='stockitem',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='stockitem',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 2.2.9 on 2020-02-17 11:09
from django.db import migrations
from stock import models
def update_stock_item_tree(apps, schema_editor):
# Update the StockItem MPTT model
models.StockItem.objects.rebuild()
class Migration(migrations.Migration):
dependencies = [
('stock', '0021_auto_20200215_2232'),
]
operations = [
migrations.RunPython(update_stock_item_tree)
]

View File

@ -18,7 +18,7 @@ from django.dispatch import receiver
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
from mptt.models import TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime from datetime import datetime
@ -102,11 +102,12 @@ def before_delete_stock_location(sender, instance, using, **kwargs):
child.save() child.save()
class StockItem(models.Model): class StockItem(MPTTModel):
""" """
A StockItem object represents a quantity of physical instances of a part. A StockItem object represents a quantity of physical instances of a part.
Attributes: Attributes:
parent: Link to another StockItem from which this StockItem was created
part: Link to the master abstract part that this StockItem is an instance of part: Link to the master abstract part that this StockItem is an instance of
supplier_part: Link to a specific SupplierPart (optional) supplier_part: Link to a specific SupplierPart (optional)
location: Where this StockItem is located location: Where this StockItem is located
@ -296,6 +297,11 @@ class StockItem(models.Model):
} }
) )
parent = TreeForeignKey('self',
on_delete=models.DO_NOTHING,
blank=True, null=True,
related_name='children')
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='stock_items', help_text=_('Base part'), related_name='stock_items', help_text=_('Base part'),
limit_choices_to={ limit_choices_to={
@ -370,15 +376,31 @@ class StockItem(models.Model):
def can_delete(self): def can_delete(self):
""" Can this stock item be deleted? It can NOT be deleted under the following circumstances: """ Can this stock item be deleted? It can NOT be deleted under the following circumstances:
- Has child StockItems
- Has a serial number and is tracked - Has a serial number and is tracked
- Is installed inside another StockItem - Is installed inside another StockItem
""" """
if self.child_count > 0:
return False
if self.part.trackable and self.serial is not None: if self.part.trackable and self.serial is not None:
return False return False
return True return True
@property
def children(self):
""" Return a list of the child items which have been split from this stock item """
return self.get_descendants(include_self=False)
@property
def child_count(self):
""" Return the number of 'child' items associated with this StockItem.
A child item is one which has been split from this one.
"""
return self.children.count()
@property @property
def in_stock(self): def in_stock(self):
@ -469,6 +491,7 @@ class StockItem(models.Model):
new_item.quantity = 1 new_item.quantity = 1
new_item.serial = serial new_item.serial = serial
new_item.pk = None new_item.pk = None
new_item.parent = self
if location: if location:
new_item.location = location new_item.location = location
@ -496,13 +519,14 @@ class StockItem(models.Model):
item.save() item.save()
@transaction.atomic @transaction.atomic
def splitStock(self, quantity, user): def splitStock(self, quantity, location, user):
""" Split this stock item into two items, in the same location. """ Split this stock item into two items, in the same location.
Stock tracking notes for this StockItem will be duplicated, Stock tracking notes for this StockItem will be duplicated,
and added to the new StockItem. and added to the new StockItem.
Args: Args:
quantity: Number of stock items to remove from this entity, and pass to the next quantity: Number of stock items to remove from this entity, and pass to the next
location: Where to move the new StockItem to
Notes: Notes:
The provided quantity will be subtracted from this item and given to the new one. The provided quantity will be subtracted from this item and given to the new one.
@ -530,7 +554,15 @@ class StockItem(models.Model):
# Nullify the PK so a new record is created # Nullify the PK so a new record is created
new_stock = StockItem.objects.get(pk=self.pk) new_stock = StockItem.objects.get(pk=self.pk)
new_stock.pk = None new_stock.pk = None
new_stock.parent = self
new_stock.quantity = quantity new_stock.quantity = quantity
# Move to the new location if specified, otherwise use current location
if location:
new_stock.location = location
else:
new_stock.location = self.location
new_stock.save() new_stock.save()
# Copy the transaction history of this part into the new one # Copy the transaction history of this part into the new one
@ -549,6 +581,11 @@ class StockItem(models.Model):
def move(self, location, notes, user, **kwargs): def move(self, location, notes, user, **kwargs):
""" Move part to a new location. """ Move part to a new location.
If less than the available quantity is to be moved,
a new StockItem is created, with the defined quantity,
and that new StockItem is moved.
The quantity is also subtracted from the existing StockItem.
Args: Args:
location: Destination location (cannot be null) location: Destination location (cannot be null)
notes: User notes notes: User notes
@ -576,8 +613,10 @@ class StockItem(models.Model):
if quantity < self.quantity: if quantity < self.quantity:
# We need to split the stock! # We need to split the stock!
# Leave behind certain quantity # Split the existing StockItem in two
self.splitStock(self.quantity - quantity, user) self.splitStock(quantity, location, user)
return True
msg = "Moved to {loc}".format(loc=str(location)) msg = "Moved to {loc}".format(loc=str(location))
@ -586,7 +625,8 @@ class StockItem(models.Model):
self.location = location self.location = location
self.addTransactionNote(msg, self.addTransactionNote(
msg,
user, user,
notes=notes, notes=notes,
system=True) system=True)
@ -727,6 +767,23 @@ class StockItem(models.Model):
return s return s
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
def before_delete_stock_item(sender, instance, using, **kwargs):
""" Receives pre_delete signal from StockItem object.
Before a StockItem is deleted, ensure that each child object is updated,
to point to the new parent item.
"""
# Update each StockItem parent field
for child in instance.children.all():
child.parent = instance.parent
child.save()
# Rebuild the MPTT tree
StockItem.objects.rebuild()
class StockItemTracking(models.Model): class StockItemTracking(models.Model):
""" Stock tracking entry - breacrumb for keeping track of automated stock transactions """ Stock tracking entry - breacrumb for keeping track of automated stock transactions

View File

@ -43,20 +43,31 @@
<button type='button' class='btn btn-default btn-glyph' id='stock-edit' title='Edit stock item'> <button type='button' class='btn btn-default btn-glyph' id='stock-edit' title='Edit stock item'>
<span class='glyphicon glyphicon-edit'/> <span class='glyphicon glyphicon-edit'/>
</button> </button>
{% if item.can_delete %}
<button type='button' class='btn btn-default btn-glyph' id='stock-delete' title='Edit stock item'> <button type='button' class='btn btn-default btn-glyph' id='stock-delete' title='Edit stock item'>
<span class='glyphicon glyphicon-trash'/> <span class='glyphicon glyphicon-trash'/>
</button> </button>
{% endif %}
</div> </div>
</p> </p>
{% if item.serialized %} {% if item.serialized %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %} {% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
</div> </div>
{% elif item.child_count > 0 %}
<div class='alert alert-block alert-warning'>
{% trans "This stock item cannot be deleted as it has child items" %}
</div>
{% elif item.delete_on_deplete %} {% elif item.delete_on_deplete %}
<div class='alert alert-block alert-warning'> <div class='alert alert-block alert-warning'>
{% trans "This stock item will be automatically deleted when all stock is depleted." %} {% trans "This stock item will be automatically deleted when all stock is depleted." %}
</div> </div>
{% endif %} {% endif %}
{% if item.parent %}
<div class='alert alert-block alert-info'>
{% trans "This stock item was split from " %}<a href="{% url 'stock-item-detail' item.parent.id %}">{{ item.parent }}</a>
</div>
{% endif %}
</div> </div>
<div class='row'> <div class='row'>

View File

@ -0,0 +1,42 @@
{% extends "stock/item_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include "stock/tabs.html" with tab='children' %}
<hr>
<h4>{% trans "Child Stock Items" %}</h4>
{% if item.child_count > 0 %}
{% include "stock_table.html" %}
{% else %}
<div class='alert alert-block alert-info'>
{% trans "This stock item does not have any child items" %}
</div>
{% endif %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% if item.child_count > 0 %}
loadStockTable($("#stock-table"), {
params: {
location_detail: true,
part_details: true,
ancestor: {{ item.id }},
},
groupByField: 'location',
buttons: [
'#stock-options',
],
url: "{% url 'api-stock-list' %}",
});
{% endif %}
{% endblock %}

View File

@ -4,6 +4,9 @@
<li{% ifequal tab 'tracking' %} class='active'{% endifequal %}> <li{% ifequal tab 'tracking' %} class='active'{% endifequal %}>
<a href="{% url 'stock-item-detail' item.id %}">{% trans "Tracking" %}</a> <a href="{% url 'stock-item-detail' item.id %}">{% trans "Tracking" %}</a>
</li> </li>
<li{% ifequal tab 'children' %} class='active'{% endifequal %}>
<a href="{% url 'stock-item-children' item.id %}">{% trans "Children" %}{% if item.child_count > 0 %}<span class='badge'>{{ item.child_count }}</span>{% endif %}</a>
</li>
{% if 0 %} {% if 0 %}
<!-- These tabs are to be implemented in the future --> <!-- These tabs are to be implemented in the future -->
<li{% ifequal tab 'builds' %} class='active'{% endifequal %}> <li{% ifequal tab 'builds' %} class='active'{% endifequal %}>

View File

@ -156,7 +156,9 @@ class StockTest(TestCase):
# Move 6 of the units # Move 6 of the units
self.assertTrue(w1.move(self.diningroom, 'Moved', None, quantity=6)) self.assertTrue(w1.move(self.diningroom, 'Moved', None, quantity=6))
self.assertEqual(w1.quantity, 6)
# There should be 4 remaining
self.assertEqual(w1.quantity, 4)
# There should also be a new object still in drawer3 # There should also be a new object still in drawer3
self.assertEqual(StockItem.objects.filter(part=25).count(), 4) self.assertEqual(StockItem.objects.filter(part=25).count(), 4)
@ -175,17 +177,17 @@ class StockTest(TestCase):
N = StockItem.objects.filter(part=3).count() N = StockItem.objects.filter(part=3).count()
stock = StockItem.objects.get(id=1234) stock = StockItem.objects.get(id=1234)
stock.splitStock(1000, None) stock.splitStock(1000, None, self.user)
self.assertEqual(stock.quantity, 234) self.assertEqual(stock.quantity, 234)
# There should be a new stock item too! # There should be a new stock item too!
self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1)
# Try to split a negative quantity # Try to split a negative quantity
stock.splitStock(-10, None) stock.splitStock(-10, None, self.user)
self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1)
stock.splitStock(stock.quantity, None) stock.splitStock(stock.quantity, None, self.user)
self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1)
def test_stocktake(self): def test_stocktake(self):
@ -325,6 +327,3 @@ class StockTest(TestCase):
# Serialize the remainder of the stock # Serialize the remainder of the stock
item.serializeStock(2, [99, 100], self.user) item.serializeStock(2, [99, 100], self.user)
# Two more items but the original has been deleted
self.assertEqual(StockItem.objects.filter(part=25).count(), n + 9)

View File

@ -24,6 +24,7 @@ stock_item_detail_urls = [
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'), url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'),
url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'), url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'),