2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-01 13:06:45 +00:00

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 || {}; var options = opts || {};
value = parseFloat(value); 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) { if (percent > 100) {
percent = 100; percent = 100;
@ -115,18 +120,28 @@ function makeProgressBar(value, maximum, opts) {
var extraclass = ''; var extraclass = '';
if (value > maximum) { if (maximum) {
// TODO - Special color?
}
else if (value > maximum) {
extraclass='progress-bar-over'; extraclass='progress-bar-over';
} else if (value < maximum) { } else if (value < maximum) {
extraclass = 'progress-bar-under'; extraclass = 'progress-bar-under';
} }
var text = value;
if (maximum) {
text += ' / ';
text += maximum;
}
var id = options.id || 'progress-bar'; var id = options.id || 'progress-bar';
return ` return `
<div id='${id}' class='progress'> <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-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> </div>
`; `;
} }

View File

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

View File

@ -777,6 +777,13 @@ class BomList(generics.ListCreateAPIView):
if sub_part is not None: if sub_part is not None:
queryset = queryset.filter(sub_part=sub_part) 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 return queryset
permission_classes = [ permission_classes = [

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from mptt.fields import TreeNodeChoiceField from mptt.fields import TreeNodeChoiceField
@ -17,6 +19,8 @@ from InvenTree.fields import RoundingDecimalFormField
from report.models import TestReport from report.models import TestReport
from part.models import Part
from .models import StockLocation, StockItem, StockItemTracking from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemAttachment from .models import StockItemAttachment
from .models import StockItemTestResult from .models import StockItemTestResult
@ -271,6 +275,59 @@ class ExportOptionsForm(HelperForm):
self.fields['file_format'].choices = self.get_format_choices() 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): class UninstallStockForm(forms.ModelForm):
""" """
Form for uninstalling a stock item which is installed in another item. 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 return self.installedItemCount() > 0
@transaction.atomic @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 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 user: The user performing the operation
notes: Any notes associated with the operation notes: Any notes associated with the operation
""" """
@ -614,18 +615,29 @@ class StockItem(MPTTModel):
if self.belongs_to is not None: if self.belongs_to is not None:
return False 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 if stock_item is None:
self.belongs_to = otherItem stock_item = otherItem
self.save() # Assign the other stock item into this one
stock_item.belongs_to = self
stock_item.save()
# Add a transaction note! # Add a transaction note to the other item
self.addTransactionNote( stock_item.addTransactionNote(
_('Installed in stock item') + ' ' + str(otherItem.pk), _('Installed into stock item') + ' ' + str(self.pk),
user, 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 @transaction.atomic
@ -645,16 +657,31 @@ class StockItem(MPTTModel):
# TODO - Are there any other checks that need to be performed at this stage? # 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.belongs_to = None
self.location = location self.location = location
self.save() self.save()
if location:
url = location.get_absolute_url()
else:
url = ''
# Add a transaction note! # Add a transaction note!
self.addTransactionNote( self.addTransactionNote(
_('Uninstalled into location') + ' ' + str(location), _('Uninstalled into location') + ' ' + str(location),
user, user,
notes=notes notes=notes,
url=url
) )
@property @property
@ -838,20 +865,20 @@ class StockItem(MPTTModel):
# Do not split a serialized part # Do not split a serialized part
if self.serialized: if self.serialized:
return return self
try: try:
quantity = Decimal(quantity) quantity = Decimal(quantity)
except (InvalidOperation, ValueError): except (InvalidOperation, ValueError):
return return self
# Doesn't make sense for a zero quantity # Doesn't make sense for a zero quantity
if quantity <= 0: if quantity <= 0:
return return self
# Also doesn't make sense to split the full amount # Also doesn't make sense to split the full amount
if quantity >= self.quantity: if quantity >= self.quantity:
return return self
# Create a new StockItem object, duplicating relevant fields # Create a new StockItem object, duplicating relevant fields
# Nullify the PK so a new record is created # 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> <h4>{% trans "Installed Stock Items" %}</h4>
<hr> <hr>
<div id='button-toolbar'> <table class='table table-striped table-condensed' id='installed-table'></table>
<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>
{% endblock %} {% endblock %}
@ -30,135 +18,14 @@
{{ block.super }} {{ block.super }}
$('#installed-table').inventreeTable({ loadInstalledInTable(
formatNoMatches: function() { $('#installed-table'),
return '{% trans "No stock items installed" %}'; {
}, stock_item: {{ item.pk }},
url: "{% url 'api-stock-list' %}", part: {{ item.part.pk }},
queryParams: { quantity: {{ item.quantity }},
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',
]
});
$('#multi-item-uninstall').click(function() { $('#multi-item-uninstall').click(function() {

View File

@ -1,6 +1,8 @@
{% extends "modal_form.html" %} {% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %} {% block pre_form_content %}
Create serialized items from this stock item.<br> {% trans "Create serialized items from this stock item." %}
Select quantity to serialize, and unique serial numbers. <br>
{% trans "Select quantity to serialize, and unique serial numbers." %}
{% endblock %} {% 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'^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'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'), 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'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),

View File

@ -683,6 +683,106 @@ class StockItemQRCode(QRCodeView):
return None 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): class StockItemUninstall(AjaxView, FormMixin):
""" """
View for uninstalling one or more StockItems, View for uninstalling one or more StockItems,

View File

@ -470,10 +470,16 @@ function loadStockTable(table, options) {
if (row.customer) { if (row.customer) {
html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`; html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`;
} else if (row.build_order) { } else {
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`; if (row.build_order) {
} else if (row.sales_order) { html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales 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 // Special stock status codes
@ -520,6 +526,9 @@ function loadStockTable(table, options) {
} else if (row.customer) { } else if (row.customer) {
var text = "{% trans "Shipped to customer" %}"; var text = "{% trans "Shipped to customer" %}";
return renderLink(text, `/company/${row.customer}/assigned-stock/`); 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) { else if (value) {
return renderLink(value, `/stock/location/${row.location}/`); return renderLink(value, `/stock/location/${row.location}/`);
@ -799,3 +808,300 @@ function createNewStockItem(options) {
launchModalForm("{% url 'stock-item-create' %}", 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();
},
}
);
}