Merge pull request #1019 from SchrodingersGat/installed-stock-improvements

Improvements for the "Installed Items" tab for StockItem display
This commit is contained in:
Oliver 2020-10-05 00:09:23 +11:00 committed by GitHub
commit 7f3ce9b0b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 581 additions and 172 deletions

View File

@ -105,9 +105,14 @@ function makeProgressBar(value, maximum, opts) {
var options = opts || {};
value = parseFloat(value);
maximum = parseFloat(maximum);
var percent = parseInt(value / maximum * 100);
var percent = 100;
// Prevent div-by-zero or null value
if (maximum && maximum > 0) {
maximum = parseFloat(maximum);
percent = parseInt(value / maximum * 100);
}
if (percent > 100) {
percent = 100;
@ -115,18 +120,28 @@ function makeProgressBar(value, maximum, opts) {
var extraclass = '';
if (value > maximum) {
if (maximum) {
// TODO - Special color?
}
else if (value > maximum) {
extraclass='progress-bar-over';
} else if (value < maximum) {
extraclass = 'progress-bar-under';
}
var text = value;
if (maximum) {
text += ' / ';
text += maximum;
}
var id = options.id || 'progress-bar';
return `
<div id='${id}' class='progress'>
<div class='progress-bar ${extraclass}' role='progressbar' aria-valuenow='${percent}' aria-valuemin='0' aria-valuemax='100' style='width:${percent}%'></div>
<div class='progress-value'>${value} / ${maximum}</div>
<div class='progress-value'>${text}</div>
</div>
`;
}

View File

@ -109,10 +109,20 @@ $.fn.inventreeTable = function(options) {
options.pagination = true;
options.pageSize = inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all'];
options.rememberOrder = true;
options.sortable = true;
options.search = true;
options.showColumns = true;
if (options.sortable == null) {
options.sortable = true;
}
if (options.search == null) {
options.search = true;
}
if (options.showColumns == null) {
options.showColumns = true;
}
// Callback to save pagination data
options.onPageChange = function(number, size) {

View File

@ -777,6 +777,13 @@ class BomList(generics.ListCreateAPIView):
if sub_part is not None:
queryset = queryset.filter(sub_part=sub_part)
# Filter by "trackable" status of the sub-part
trackable = self.request.query_params.get('trackable', None)
if trackable is not None:
trackable = str2bool(trackable)
queryset = queryset.filter(sub_part__trackable=trackable)
return queryset
permission_classes = [

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
from django import forms
from django.forms.utils import ErrorDict
from django.utils.translation import ugettext as _
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from mptt.fields import TreeNodeChoiceField
@ -17,6 +19,8 @@ from InvenTree.fields import RoundingDecimalFormField
from report.models import TestReport
from part.models import Part
from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemAttachment
from .models import StockItemTestResult
@ -271,6 +275,59 @@ class ExportOptionsForm(HelperForm):
self.fields['file_format'].choices = self.get_format_choices()
class InstallStockForm(HelperForm):
"""
Form for manually installing a stock item into another stock item
"""
part = forms.ModelChoiceField(
queryset=Part.objects.all(),
widget=forms.HiddenInput()
)
stock_item = forms.ModelChoiceField(
required=True,
queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER),
help_text=_('Stock item to install')
)
quantity_to_install = RoundingDecimalFormField(
max_digits=10, decimal_places=5,
initial=1,
label=_('Quantity'),
help_text=_('Stock quantity to assign'),
validators=[
MinValueValidator(0.001)
]
)
notes = forms.CharField(
required=False,
help_text=_('Notes')
)
class Meta:
model = StockItem
fields = [
'part',
'stock_item',
'quantity_to_install',
'notes',
]
def clean(self):
data = super().clean()
stock_item = data.get('stock_item', None)
quantity = data.get('quantity_to_install', None)
if stock_item and quantity and quantity > stock_item.quantity:
raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')})
return data
class UninstallStockForm(forms.ModelForm):
"""
Form for uninstalling a stock item which is installed in another item.

View File

@ -600,12 +600,13 @@ class StockItem(MPTTModel):
return self.installedItemCount() > 0
@transaction.atomic
def installIntoStockItem(self, otherItem, user, notes):
def installStockItem(self, otherItem, quantity, user, notes):
"""
Install this stock item into another stock item.
Install another stock item into this stock item.
Args
otherItem: The stock item to install this item into
otherItem: The stock item to install into this stock item
quantity: The quantity of stock to install
user: The user performing the operation
notes: Any notes associated with the operation
"""
@ -614,18 +615,29 @@ class StockItem(MPTTModel):
if self.belongs_to is not None:
return False
# TODO - Are there any other checks that need to be performed at this stage?
# If the quantity is less than the stock item, split the stock!
stock_item = otherItem.splitStock(quantity, None, user)
# Mark this stock item as belonging to the other one
self.belongs_to = otherItem
self.save()
if stock_item is None:
stock_item = otherItem
# Add a transaction note!
self.addTransactionNote(
_('Installed in stock item') + ' ' + str(otherItem.pk),
# Assign the other stock item into this one
stock_item.belongs_to = self
stock_item.save()
# Add a transaction note to the other item
stock_item.addTransactionNote(
_('Installed into stock item') + ' ' + str(self.pk),
user,
notes=notes
notes=notes,
url=self.get_absolute_url()
)
# Add a transaction note to this item
self.addTransactionNote(
_('Installed stock item') + ' ' + str(stock_item.pk),
user, notes=notes,
url=stock_item.get_absolute_url()
)
@transaction.atomic
@ -645,16 +657,31 @@ class StockItem(MPTTModel):
# TODO - Are there any other checks that need to be performed at this stage?
# Add a transaction note to the parent item
self.belongs_to.addTransactionNote(
_("Uninstalled stock item") + ' ' + str(self.pk),
user,
notes=notes,
url=self.get_absolute_url(),
)
# Mark this stock item as *not* belonging to anyone
self.belongs_to = None
self.location = location
self.save()
if location:
url = location.get_absolute_url()
else:
url = ''
# Add a transaction note!
self.addTransactionNote(
_('Uninstalled into location') + ' ' + str(location),
user,
notes=notes
notes=notes,
url=url
)
@property
@ -838,20 +865,20 @@ class StockItem(MPTTModel):
# Do not split a serialized part
if self.serialized:
return
return self
try:
quantity = Decimal(quantity)
except (InvalidOperation, ValueError):
return
return self
# Doesn't make sense for a zero quantity
if quantity <= 0:
return
return self
# Also doesn't make sense to split the full amount
if quantity >= self.quantity:
return
return self
# Create a new StockItem object, duplicating relevant fields
# Nullify the PK so a new record is created

View File

@ -0,0 +1,17 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<p>
{% trans "Install another StockItem into this item." %}
</p>
<p>
{% trans "Stock items can only be installed if they meet the following criteria" %}:
<ul>
<li>{% trans "The StockItem links to a Part which is in the BOM for this StockItem" %}</li>
<li>{% trans "The StockItem is currently in stock" %}</li>
</ul>
</p>
{% endblock %}

View File

@ -10,19 +10,7 @@
<h4>{% trans "Installed Stock Items" %}</h4>
<hr>
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class="btn-group">
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href="#" id='multi-item-uninstall' title='{% trans "Uninstall selected stock items" %}'>{% trans "Uninstall" %}</a></li>
</ul>
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='installed-table' data-toolbar='#button-toolbar'>
</table>
<table class='table table-striped table-condensed' id='installed-table'></table>
{% endblock %}
@ -30,135 +18,14 @@
{{ block.super }}
$('#installed-table').inventreeTable({
formatNoMatches: function() {
return '{% trans "No stock items installed" %}';
},
url: "{% url 'api-stock-list' %}",
queryParams: {
installed_in: {{ item.id }},
part_detail: true,
},
name: 'stock-item-installed',
url: "{% url 'api-stock-list' %}",
showColumns: true,
columns: [
{
checkbox: true,
title: '{% trans 'Select' %}',
searchable: false,
switchable: false,
},
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part_name',
title: '{% trans "Part" %}',
sortable: true,
formatter: function(value, row, index, field) {
var url = `/stock/item/${row.pk}/`;
var thumb = row.part_detail.thumbnail;
var name = row.part_detail.full_name;
html = imageHoverIcon(thumb) + renderLink(name, url);
return html;
}
},
{
field: 'IPN',
title: 'IPN',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.IPN;
},
},
{
field: 'part_description',
title: '{% trans "Description" %}',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.description;
}
},
{
field: 'quantity',
title: '{% trans "Stock" %}',
sortable: true,
formatter: function(value, row, index, field) {
var val = parseFloat(value);
// If there is a single unit with a serial number, use the serial number
if (row.serial && row.quantity == 1) {
val = '# ' + row.serial;
} else {
val = +val.toFixed(5);
}
var html = renderLink(val, `/stock/item/${row.pk}/`);
return html;
}
},
{
field: 'status',
title: '{% trans "Status" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
return stockStatusDisplay(value);
},
},
{
field: 'batch',
title: '{% trans "Batch" %}',
sortable: true,
},
{
field: 'actions',
switchable: false,
title: '',
formatter: function(value, row) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-unlink', 'button-uninstall', pk, '{% trans "Uninstall item" %}');
html += `</div>`;
return html;
}
}
],
onLoadSuccess: function() {
var table = $('#installed-table');
// Find buttons and associate actions
table.find('.button-uninstall').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
"{% url 'stock-item-uninstall' %}",
{
data: {
'items[]': [pk],
},
reload: true,
}
);
});
},
buttons: [
'#stock-options',
]
});
loadInstalledInTable(
$('#installed-table'),
{
stock_item: {{ item.pk }},
part: {{ item.part.pk }},
quantity: {{ item.quantity }},
}
);
$('#multi-item-uninstall').click(function() {

View File

@ -1,6 +1,8 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
Create serialized items from this stock item.<br>
Select quantity to serialize, and unique serial numbers.
{% trans "Create serialized items from this stock item." %}
<br>
{% trans "Select quantity to serialize, and unique serial numbers." %}
{% endblock %}

View File

@ -25,6 +25,7 @@ stock_item_detail_urls = [
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'),
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),

View File

@ -683,6 +683,106 @@ class StockItemQRCode(QRCodeView):
return None
class StockItemInstall(AjaxUpdateView):
"""
View for manually installing stock items into
a particular stock item.
In contrast to the StockItemUninstall view,
only a single stock item can be installed at once.
The "part" to be installed must be provided in the GET query parameters.
"""
model = StockItem
form_class = StockForms.InstallStockForm
ajax_form_title = _('Install Stock Item')
ajax_template_name = "stock/item_install.html"
part = None
def get_stock_items(self):
"""
Return a list of stock items suitable for displaying to the user.
Requirements:
- Items must be in stock
Filters:
- Items can be filtered by Part reference
"""
items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
# Filter by Part association
# Look at GET params
part_id = self.request.GET.get('part', None)
if part_id is None:
# Look at POST params
part_id = self.request.POST.get('part', None)
try:
self.part = Part.objects.get(pk=part_id)
items = items.filter(part=self.part)
except (ValueError, Part.DoesNotExist):
self.part = None
return items
def get_initial(self):
initials = super().get_initial()
items = self.get_stock_items()
# If there is a single stock item available, we can use it!
if items.count() == 1:
item = items.first()
initials['stock_item'] = item.pk
initials['quantity_to_install'] = item.quantity
if self.part:
initials['part'] = self.part
return initials
def get_form(self):
form = super().get_form()
form.fields['stock_item'].queryset = self.get_stock_items()
return form
def post(self, request, *args, **kwargs):
form = self.get_form()
valid = form.is_valid()
if valid:
# We assume by this point that we have a valid stock_item and quantity values
data = form.cleaned_data
other_stock_item = data['stock_item']
quantity = data['quantity_to_install']
notes = data['notes']
# Install the other stock item into this one
this_stock_item = self.get_object()
this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes)
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data=data)
class StockItemUninstall(AjaxView, FormMixin):
"""
View for uninstalling one or more StockItems,

View File

@ -470,10 +470,16 @@ function loadStockTable(table, options) {
if (row.customer) {
html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`;
} else if (row.build_order) {
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
} else if (row.sales_order) {
html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales order" %}'></span>`;
} else {
if (row.build_order) {
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
} else if (row.sales_order) {
html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales order" %}'></span>`;
}
}
if (row.belongs_to) {
html += `<span class='fas fa-box label-right' title='{% trans "Stock item has been installed in another item" %}'></span>`;
}
// Special stock status codes
@ -520,6 +526,9 @@ function loadStockTable(table, options) {
} else if (row.customer) {
var text = "{% trans "Shipped to customer" %}";
return renderLink(text, `/company/${row.customer}/assigned-stock/`);
} else if (row.sales_order) {
var text = `{% trans "Assigned to sales order" %}`;
return renderLink(text, `/order/sales-order/${row.sales_order}/`);
}
else if (value) {
return renderLink(value, `/stock/location/${row.location}/`);
@ -798,4 +807,301 @@ function createNewStockItem(options) {
];
launchModalForm("{% url 'stock-item-create' %}", options);
}
function loadInstalledInTable(table, options) {
/*
* Display a table showing the stock items which are installed in this stock item.
* This is a multi-level tree table, where the "top level" items are Part objects,
* and the children of each top-level item are the associated installed stock items.
*
* The process for retrieving data and displaying the table is as follows:
*
* A) Get BOM data for the stock item
* - It is assumed that the stock item will be for an assembly
* (otherwise why are we installing stuff anyway?)
* - Request BOM items for stock_item.part (and only for trackable sub items)
*
* B) Add parts to table
* - Create rows for each trackable sub-part in the table
*
* C) Gather installed stock item data
* - Get the list of installed stock items via the API
* - If the Part reference is already in the table, add the sub-item as a child
* - If this is a stock item for a *new* part, request that part from the API,
* and add that part as a new row, then add the stock item as a child of that part
*
* D) Enjoy!
*
*
* And the options object contains the following things:
*
* - stock_item: The PK of the master stock_item object
* - part: The PK of the Part reference of the stock_item object
* - quantity: The quantity of the stock item
*/
function updateCallbacks() {
// Setup callback functions when buttons are pressed
table.find('.button-install').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/stock/item/${options.stock_item}/install/`,
{
data: {
part: pk,
},
success: function() {
// Refresh entire table!
table.bootstrapTable('refresh');
}
}
);
});
}
table.inventreeTable(
{
url: "{% url 'api-bom-list' %}",
queryParams: {
part: options.part,
trackable: true,
sub_part_detail: true,
},
showColumns: false,
name: 'installed-in',
detailView: true,
detailViewByClick: true,
detailFilter: function(index, row) {
return row.installed_count && row.installed_count > 0;
},
detailFormatter: function(index, row, element) {
var subTableId = `installed-table-${row.sub_part}`;
var html = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
element.html(html);
var subTable = $(`#${subTableId}`);
// Display a "sub table" showing all the linked stock items
subTable.bootstrapTable({
data: row.installed_items,
showHeader: true,
columns: [
{
field: 'item',
title: '{% trans "Stock Item" %}',
formatter: function(value, subrow, index, field) {
var pk = subrow.pk;
var html = '';
if (subrow.serial && subrow.quantity == 1) {
html += `{% trans "Serial" %}: ${subrow.serial}`;
} else {
html += `{% trans "Quantity" %}: ${subrow.quantity}`;
}
return renderLink(html, `/stock/item/${subrow.pk}/`);
},
},
{
field: 'status',
title: '{% trans "Status" %}',
formatter: function(value, subrow, index, field) {
return stockStatusDisplay(value);
}
},
{
field: 'batch',
title: '{% trans "Batch" %}',
},
{
field: 'actions',
title: '',
formatter: function(value, subrow, index) {
var pk = subrow.pk;
var html = '';
// Add some buttons yo!
html += `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-unlink', 'button-uninstall', pk, "{% trans "Uninstall stock item" %}");
html += `</div>`;
return html;
}
}
],
onPostBody: function() {
// Setup button callbacks
subTable.find('.button-uninstall').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
"{% url 'stock-item-uninstall' %}",
{
data: {
'items[]': [pk],
},
success: function() {
// Refresh entire table!
table.bootstrapTable('refresh');
}
}
);
});
}
});
},
columns: [
{
checkbox: true,
title: '{% trans 'Select' %}',
searchable: false,
switchable: false,
},
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part',
title: '{% trans "Part" %}',
sortable: true,
formatter: function(value, row, index, field) {
var url = `/part/${row.sub_part}/`;
var thumb = row.sub_part_detail.thumbnail;
var name = row.sub_part_detail.full_name;
html = imageHoverIcon(thumb) + renderLink(name, url);
if (row.not_in_bom) {
html = `<i>${html}</i>`
}
return html;
}
},
{
field: 'installed',
title: '{% trans "Installed" %}',
sortable: false,
formatter: function(value, row, index, field) {
// Construct a progress showing how many items have been installed
var installed = row.installed_count || 0;
var required = row.quantity || 0;
required *= options.quantity;
var progress = makeProgressBar(installed, required, {
id: row.sub_part.pk,
});
return progress;
}
},
{
field: 'actions',
switchable: false,
formatter: function(value, row) {
var pk = row.sub_part;
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-link', 'button-install', pk, '{% trans "Install item" %}');
html += `</div>`;
return html;
}
}
],
onLoadSuccess: function() {
// Grab a list of parts which are actually installed in this stock item
inventreeGet(
"{% url 'api-stock-list' %}",
{
installed_in: options.stock_item,
part_detail: true,
},
{
success: function(stock_items) {
var table_data = table.bootstrapTable('getData');
stock_items.forEach(function(item) {
var match = false;
for (var idx = 0; idx < table_data.length; idx++) {
var row = table_data[idx];
// Check each row in the table to see if this stock item matches
table_data.forEach(function(row) {
// Match on "sub_part"
if (row.sub_part == item.part) {
// First time?
if (row.installed_count == null) {
row.installed_count = 0;
row.installed_items = [];
}
row.installed_count += item.quantity;
row.installed_items.push(item);
// Push the row back into the table
table.bootstrapTable('updateRow', idx, row, true);
match = true;
}
});
if (match) {
break;
}
}
if (!match) {
// The stock item did *not* match any items in the BOM!
// Add a new row to the table...
// Contruct a new "row" to add to the table
var new_row = {
sub_part: item.part,
sub_part_detail: item.part_detail,
not_in_bom: true,
installed_count: item.quantity,
installed_items: [item],
};
table.bootstrapTable('append', [new_row]);
}
});
// Update button callback links
updateCallbacks();
}
}
);
updateCallbacks();
},
}
);
}