mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into plugin-2037
This commit is contained in:
commit
31a8ee7302
@ -933,7 +933,8 @@ input[type="submit"] {
|
||||
|
||||
.panel-inventree {
|
||||
padding: 10px;
|
||||
box-shadow: 1px 1px #DDD;
|
||||
box-shadow: 2px 2px #DDD;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.panel-hidden {
|
||||
@ -1074,6 +1075,14 @@ input[type='number']{
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
width: 20%;
|
||||
padding: 5px;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
.product-card-panel{
|
||||
height: 100%;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 2px 2px #DDD;
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
var msDelay = 0;
|
||||
|
||||
var delay = (function(){
|
||||
return function(callback, ms){
|
||||
clearTimeout(msDelay);
|
||||
msDelay = setTimeout(callback, ms);
|
||||
};
|
||||
})();
|
||||
|
||||
function cancelTimer(){
|
||||
clearTimeout(msDelay);
|
||||
}
|
@ -1,249 +0,0 @@
|
||||
function loadTree(url, tree, options={}) {
|
||||
/* Load the side-nav tree view
|
||||
|
||||
Args:
|
||||
url: URL to request tree data
|
||||
tree: html ref to treeview
|
||||
options:
|
||||
data: data object to pass to the AJAX request
|
||||
selected: ID of currently selected item
|
||||
name: name of the tree
|
||||
*/
|
||||
|
||||
var data = {};
|
||||
|
||||
if (options.data) {
|
||||
data = options.data;
|
||||
}
|
||||
|
||||
var key = "inventree-sidenav-items-";
|
||||
|
||||
if (options.name) {
|
||||
key += options.name;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'get',
|
||||
dataType: 'json',
|
||||
data: data,
|
||||
success: function (response) {
|
||||
if (response.tree) {
|
||||
$(tree).treeview({
|
||||
data: response.tree,
|
||||
enableLinks: true,
|
||||
showTags: true,
|
||||
});
|
||||
|
||||
if (localStorage.getItem(key)) {
|
||||
var saved_exp = localStorage.getItem(key).split(",");
|
||||
|
||||
// Automatically expand the desired notes
|
||||
for (var q = 0; q < saved_exp.length; q++) {
|
||||
$(tree).treeview('expandNode', parseInt(saved_exp[q]));
|
||||
}
|
||||
}
|
||||
|
||||
// Setup a callback whenever a node is toggled
|
||||
$(tree).on('nodeExpanded nodeCollapsed', function(event, data) {
|
||||
|
||||
// Record the entire list of expanded items
|
||||
var expanded = $(tree).treeview('getExpanded');
|
||||
|
||||
var exp = [];
|
||||
|
||||
for (var i = 0; i < expanded.length; i++) {
|
||||
exp.push(expanded[i].nodeId);
|
||||
}
|
||||
|
||||
// Save the expanded nodes
|
||||
localStorage.setItem(key, exp);
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function (xhr, ajaxOptions, thrownError) {
|
||||
//TODO
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize navigation tree display
|
||||
*/
|
||||
function initNavTree(options) {
|
||||
|
||||
var resize = true;
|
||||
|
||||
if ('resize' in options) {
|
||||
resize = options.resize;
|
||||
}
|
||||
|
||||
var label = options.label || 'nav';
|
||||
|
||||
var stateLabel = `${label}-tree-state`;
|
||||
var widthLabel = `${label}-tree-width`;
|
||||
|
||||
var treeId = options.treeId || '#sidenav-left';
|
||||
var toggleId = options.toggleId;
|
||||
|
||||
// Initially hide the tree
|
||||
$(treeId).animate({
|
||||
width: '0px',
|
||||
}, 0, function() {
|
||||
|
||||
if (resize) {
|
||||
$(treeId).resizable({
|
||||
minWidth: '0px',
|
||||
maxWidth: '500px',
|
||||
handles: 'e, se',
|
||||
grid: [5, 5],
|
||||
stop: function(event, ui) {
|
||||
var width = Math.round(ui.element.width());
|
||||
|
||||
if (width < 75) {
|
||||
$(treeId).animate({
|
||||
width: '0px'
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
} else {
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
localStorage.setItem(widthLabel, `${width}px`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var state = localStorage.getItem(stateLabel);
|
||||
var width = localStorage.getItem(widthLabel) || '300px';
|
||||
|
||||
if (state && state == 'open') {
|
||||
|
||||
$(treeId).animate({
|
||||
width: width,
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Register callback for 'toggle' button
|
||||
if (toggleId) {
|
||||
|
||||
$(toggleId).click(function() {
|
||||
|
||||
var state = localStorage.getItem(stateLabel) || 'closed';
|
||||
var width = localStorage.getItem(widthLabel) || '300px';
|
||||
|
||||
if (state == 'open') {
|
||||
$(treeId).animate({
|
||||
width: '0px'
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
} else {
|
||||
$(treeId).animate({
|
||||
width: width,
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle left-hand icon menubar display
|
||||
*/
|
||||
function enableNavbar(options) {
|
||||
|
||||
var resize = true;
|
||||
|
||||
if ('resize' in options) {
|
||||
resize = options.resize;
|
||||
}
|
||||
|
||||
var label = options.label || 'nav';
|
||||
|
||||
label = `navbar-${label}`;
|
||||
|
||||
var stateLabel = `${label}-state`;
|
||||
var widthLabel = `${label}-width`;
|
||||
|
||||
var navId = options.navId || '#sidenav-right';
|
||||
|
||||
var toggleId = options.toggleId;
|
||||
|
||||
// Extract the saved width for this element
|
||||
$(navId).animate({
|
||||
width: '45px',
|
||||
'min-width': '45px',
|
||||
display: 'block',
|
||||
}, 50, function() {
|
||||
|
||||
// Make the navbar resizable
|
||||
if (resize) {
|
||||
$(navId).resizable({
|
||||
minWidth: options.minWidth || '100px',
|
||||
maxWidth: options.maxWidth || '500px',
|
||||
handles: 'e, se',
|
||||
grid: [5, 5],
|
||||
stop: function(event, ui) {
|
||||
// Record the new width
|
||||
var width = Math.round(ui.element.width());
|
||||
|
||||
// Reasonably narrow? Just close it!
|
||||
if (width <= 75) {
|
||||
$(navId).animate({
|
||||
width: '45px'
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
} else {
|
||||
localStorage.setItem(widthLabel, `${width}px`);
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var state = localStorage.getItem(stateLabel);
|
||||
|
||||
var width = localStorage.getItem(widthLabel) || '250px';
|
||||
|
||||
if (state && state == 'open') {
|
||||
|
||||
$(navId).animate({
|
||||
width: width
|
||||
}, 100);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Register callback for 'toggle' button
|
||||
if (toggleId) {
|
||||
|
||||
$(toggleId).click(function() {
|
||||
|
||||
var state = localStorage.getItem(stateLabel) || 'closed';
|
||||
var width = localStorage.getItem(widthLabel) || '250px';
|
||||
|
||||
if (state == 'open') {
|
||||
$(navId).animate({
|
||||
width: '45px',
|
||||
minWidth: '45px',
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
|
||||
} else {
|
||||
|
||||
$(navId).animate({
|
||||
'width': width
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -12,11 +12,14 @@ import common.models
|
||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 15
|
||||
INVENTREE_API_VERSION = 16
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v16 -> 2021-10-17
|
||||
- Adds API endpoint for completing build order outputs
|
||||
|
||||
v15 -> 2021-10-06
|
||||
- Adds detail endpoint for SalesOrderAllocation model
|
||||
- Allows use of the API forms interface for adjusting SalesOrderAllocation objects
|
||||
|
@ -5,12 +5,9 @@ JSON API for the Build app
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.conf.urls import url, include
|
||||
|
||||
from rest_framework import filters, generics
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as rest_filters
|
||||
@ -21,7 +18,7 @@ from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
|
||||
from .models import Build, BuildItem, BuildOrderAttachment
|
||||
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
||||
from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer
|
||||
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
|
||||
|
||||
|
||||
@ -202,29 +199,42 @@ class BuildUnallocate(generics.CreateAPIView):
|
||||
|
||||
serializer_class = BuildUnallocationSerializer
|
||||
|
||||
def get_build(self):
|
||||
"""
|
||||
Returns the BuildOrder associated with this API endpoint
|
||||
"""
|
||||
|
||||
pk = self.kwargs.get('pk', None)
|
||||
|
||||
try:
|
||||
build = Build.objects.get(pk=pk)
|
||||
except (ValueError, Build.DoesNotExist):
|
||||
raise ValidationError(_("Matching build order does not exist"))
|
||||
|
||||
return build
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['build'] = self.get_build()
|
||||
|
||||
try:
|
||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildComplete(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for completing build outputs
|
||||
"""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = BuildCompleteSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
try:
|
||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildAllocate(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocate stock items to a build order
|
||||
@ -241,20 +251,6 @@ class BuildAllocate(generics.CreateAPIView):
|
||||
|
||||
serializer_class = BuildAllocationSerializer
|
||||
|
||||
def get_build(self):
|
||||
"""
|
||||
Returns the BuildOrder associated with this API endpoint
|
||||
"""
|
||||
|
||||
pk = self.kwargs.get('pk', None)
|
||||
|
||||
try:
|
||||
build = Build.objects.get(pk=pk)
|
||||
except (Build.DoesNotExist, ValueError):
|
||||
raise ValidationError(_("Matching build order does not exist"))
|
||||
|
||||
return build
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
Provide the Build object to the serializer context
|
||||
@ -262,7 +258,11 @@ class BuildAllocate(generics.CreateAPIView):
|
||||
|
||||
context = super().get_serializer_context()
|
||||
|
||||
context['build'] = self.get_build()
|
||||
try:
|
||||
context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
@ -390,6 +390,7 @@ build_api_urls = [
|
||||
# Build Detail
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
url(r'^complete/', BuildComplete.as_view(), name='api-build-complete'),
|
||||
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||
])),
|
||||
|
@ -10,63 +10,9 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from django import forms
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.fields import DatePickerFormField
|
||||
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
from .models import Build
|
||||
|
||||
from stock.models import StockLocation, StockItem
|
||||
|
||||
|
||||
class EditBuildForm(HelperForm):
|
||||
""" Form for editing a Build object.
|
||||
"""
|
||||
|
||||
field_prefix = {
|
||||
'reference': 'BO',
|
||||
'link': 'fa-link',
|
||||
'batch': 'fa-layer-group',
|
||||
'serial-numbers': 'fa-hashtag',
|
||||
'location': 'fa-map-marker-alt',
|
||||
'target_date': 'fa-calendar-alt',
|
||||
}
|
||||
|
||||
field_placeholder = {
|
||||
'reference': _('Build Order reference'),
|
||||
'target_date': _('Order target date'),
|
||||
}
|
||||
|
||||
target_date = DatePickerFormField(
|
||||
label=_('Target Date'),
|
||||
help_text=_('Target date for build completion. Build will be overdue after this date.')
|
||||
)
|
||||
|
||||
quantity = RoundingDecimalFormField(
|
||||
max_digits=10, decimal_places=5,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Number of items to build')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Build
|
||||
fields = [
|
||||
'reference',
|
||||
'title',
|
||||
'part',
|
||||
'quantity',
|
||||
'batch',
|
||||
'target_date',
|
||||
'take_from',
|
||||
'destination',
|
||||
'parent',
|
||||
'sales_order',
|
||||
'link',
|
||||
'issued_by',
|
||||
'responsible',
|
||||
]
|
||||
|
||||
|
||||
class BuildOutputCreateForm(HelperForm):
|
||||
"""
|
||||
@ -155,59 +101,6 @@ class CompleteBuildForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class CompleteBuildOutputForm(HelperForm):
|
||||
"""
|
||||
Form for completing a single build output
|
||||
"""
|
||||
|
||||
field_prefix = {
|
||||
'serial_numbers': 'fa-hashtag',
|
||||
}
|
||||
|
||||
field_placeholder = {
|
||||
}
|
||||
|
||||
location = forms.ModelChoiceField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
label=_('Location'),
|
||||
help_text=_('Location of completed parts'),
|
||||
)
|
||||
|
||||
stock_status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
help_text=_('Build output stock status'),
|
||||
initial=StockStatus.OK,
|
||||
choices=StockStatus.items(),
|
||||
)
|
||||
|
||||
confirm_incomplete = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Confirm incomplete'),
|
||||
help_text=_("Confirm completion with incomplete stock allocation")
|
||||
)
|
||||
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm build completion'))
|
||||
|
||||
output = forms.ModelChoiceField(
|
||||
queryset=StockItem.objects.all(), # Queryset is narrowed in the view
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Build
|
||||
fields = [
|
||||
'location',
|
||||
'output',
|
||||
'stock_status',
|
||||
'confirm',
|
||||
'confirm_incomplete',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class CancelBuildForm(HelperForm):
|
||||
""" Form for cancelling a build """
|
||||
|
||||
|
@ -724,7 +724,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
items.all().delete()
|
||||
|
||||
@transaction.atomic
|
||||
def completeBuildOutput(self, output, user, **kwargs):
|
||||
def complete_build_output(self, output, user, **kwargs):
|
||||
"""
|
||||
Complete a particular build output
|
||||
|
||||
@ -741,10 +741,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
allocated_items = output.items_to_install.all()
|
||||
|
||||
for build_item in allocated_items:
|
||||
|
||||
# TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete
|
||||
# TODO: Use the background worker process to handle this task!
|
||||
|
||||
# Complete the allocation of stock for that item
|
||||
build_item.complete_allocation(user)
|
||||
|
||||
@ -770,6 +766,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
# Increase the completed quantity for this build
|
||||
self.completed += output.quantity
|
||||
|
||||
self.save()
|
||||
|
||||
def requiredQuantity(self, part, output):
|
||||
|
@ -18,9 +18,10 @@ from rest_framework.serializers import ValidationError
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
|
||||
|
||||
from InvenTree.status_codes import StockStatus
|
||||
import InvenTree.helpers
|
||||
|
||||
from stock.models import StockItem
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.serializers import StockItemSerializerBrief, LocationSerializer
|
||||
|
||||
from part.models import BomItem
|
||||
@ -120,6 +121,124 @@ class BuildSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class BuildOutputSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for a "BuildOutput"
|
||||
|
||||
Note that a "BuildOutput" is really just a StockItem which is "in production"!
|
||||
"""
|
||||
|
||||
output = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Build Output'),
|
||||
)
|
||||
|
||||
def validate_output(self, output):
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
# The stock item must point to the build
|
||||
if output.build != build:
|
||||
raise ValidationError(_("Build output does not match the parent build"))
|
||||
|
||||
# The part must match!
|
||||
if output.part != build.part:
|
||||
raise ValidationError(_("Output part does not match BuildOrder part"))
|
||||
|
||||
# The build output must be "in production"
|
||||
if not output.is_building:
|
||||
raise ValidationError(_("This build output has already been completed"))
|
||||
|
||||
# The build output must have all tracked parts allocated
|
||||
if not build.isFullyAllocated(output):
|
||||
raise ValidationError(_("This build output is not fully allocated"))
|
||||
|
||||
return output
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'output',
|
||||
]
|
||||
|
||||
|
||||
class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for completing one or more build outputs
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'outputs',
|
||||
'location',
|
||||
'status',
|
||||
'notes',
|
||||
]
|
||||
|
||||
outputs = BuildOutputSerializer(
|
||||
many=True,
|
||||
required=True,
|
||||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
required=True,
|
||||
many=False,
|
||||
label=_("Location"),
|
||||
help_text=_("Location for completed build outputs"),
|
||||
)
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=list(StockStatus.items()),
|
||||
default=StockStatus.OK,
|
||||
label=_("Status"),
|
||||
)
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_("Notes"),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
super().validate(data)
|
||||
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
if len(outputs) == 0:
|
||||
raise ValidationError(_("A list of build outputs must be provided"))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
"save" the serializer to complete the build outputs
|
||||
"""
|
||||
|
||||
build = self.context['build']
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
# Mark the specified build outputs as "complete"
|
||||
with transaction.atomic():
|
||||
for item in outputs:
|
||||
|
||||
output = item['output']
|
||||
|
||||
build.complete_build_output(
|
||||
output,
|
||||
request.user,
|
||||
status=data['status'],
|
||||
notes=data.get('notes', '')
|
||||
)
|
||||
|
||||
|
||||
class BuildUnallocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for unallocating stock from a BuildOrder
|
||||
@ -190,6 +309,8 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
|
||||
def validate_bom_item(self, bom_item):
|
||||
|
||||
# TODO: Fix this validation - allow for variants and substitutes!
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
# BomItem must point to the same 'part' as the parent build
|
||||
|
@ -1,51 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% define item.pk as pk %}
|
||||
|
||||
<div class="panel panel-default" id='allocation-panel-{{ pk }}'>
|
||||
<div class="panel-heading" role="tab" id="heading-{{ pk }}">
|
||||
<div class="panel-title">
|
||||
<div class='row'>
|
||||
{% if tracked_items %}
|
||||
<a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}">
|
||||
{% endif %}
|
||||
<div class='col-sm-4'>
|
||||
{% if tracked_items %}
|
||||
<span class='fas fa-caret-right'></span>
|
||||
{% endif %}
|
||||
{{ item.part.full_name }}
|
||||
</div>
|
||||
<div class='col-sm-2'>
|
||||
{% if item.serial %}
|
||||
{% trans "Serial Number" %}: {{ item.serial }}
|
||||
{% else %}
|
||||
{% trans "Quantity" %}: {% decimal item.quantity %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if tracked_items %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class='col-sm-3'>
|
||||
<div>
|
||||
<div id='output-progress-{{ pk }}'>
|
||||
{% if tracked_items %}
|
||||
<span class='fas fa-spin fa-spinner'></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='col-sm-3'>
|
||||
<div class='btn-group float-right' id='output-actions-{{ pk }}'>
|
||||
<span class='fas fa-spin fa-spinner'></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collapse-{{ pk }}" class="panel-collapse collapse" role="tabpanel" aria-labelledby="heading-{{ pk }}">
|
||||
<div class="panel-body">
|
||||
<table class='table table-striped table-condensed' id='allocation-table-{{ pk }}'></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -91,16 +91,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print Build Order" %}</a></li>
|
||||
<li><a href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print build order report" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Build actions -->
|
||||
{% if roles.build.change %}
|
||||
{% if build.active %}
|
||||
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
|
||||
<span class='fas fa-paper-plane'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class='btn-group'>
|
||||
<button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
@ -115,6 +110,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if build.active %}
|
||||
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
|
||||
<span class='fas fa-check-circle'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -153,8 +153,8 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-spinner'></span></td>
|
||||
<td>{% trans "Progress" %}</td>
|
||||
<td><span class='fas fa-check-circle'></span></td>
|
||||
<td>{% trans "Completed" %}</td>
|
||||
<td> {{ build.completed }} / {{ build.quantity }}</td>
|
||||
</tr>
|
||||
{% if build.parent %}
|
||||
|
@ -1,53 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
{% if not build.has_tracked_bom_items %}
|
||||
{% elif fully_allocated %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Stock allocation is complete for this output" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<h4>{% trans "Stock allocation is incomplete" %}</h4>
|
||||
|
||||
<div class='panel-group'>
|
||||
<div class='panel panel-default'>
|
||||
<div class='panel panel-heading'>
|
||||
<a data-toggle='collapse' href='#collapse-unallocated'>
|
||||
{{ unallocated_parts|length }} {% trans "tracked parts have not been fully allocated" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class='panel-collapse collapse' id='collapse-unallocated'>
|
||||
<div class='panel-body'>
|
||||
<ul class='list-group'>
|
||||
{% for part in unallocated_parts %}
|
||||
<li class='list-group-item'>
|
||||
{% include "hover_image.html" with image=part.image %} {{ part }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class='panel panel-info'>
|
||||
<div class='panel-heading'>
|
||||
{% trans "The following items will be created" %}
|
||||
</div>
|
||||
<div class='panel-content' style='padding-bottom:16px'>
|
||||
{% include "hover_image.html" with image=build.part.image %}
|
||||
{% if output.serialized %}
|
||||
{{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }}
|
||||
{% else %}
|
||||
{% decimal output.quantity %} x {{ output.part.full_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -63,10 +63,17 @@
|
||||
<td>{% build_status_label build.status %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-spinner'></span></td>
|
||||
<td>{% trans "Progress" %}</td>
|
||||
<td><span class='fas fa-check-circle'></span></td>
|
||||
<td>{% trans "Completed" %}</td>
|
||||
<td>{{ build.completed }} / {{ build.quantity }}</td>
|
||||
</tr>
|
||||
{% if build.active and build.has_untracked_bom_items %}
|
||||
<tr>
|
||||
<td><span class='fas fa-list'></span></td>
|
||||
<td>{% trans "Allocated Parts" %}</td>
|
||||
<td id='output-progress-untracked'><span class='fas fa-spinner fa-spin'></span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.batch %}
|
||||
<tr>
|
||||
<td><span class='fas fa-layer-group'></span></td>
|
||||
@ -213,35 +220,35 @@
|
||||
</div>
|
||||
|
||||
<div class='panel panel-default panel-inventree panel-hidden' id='panel-outputs'>
|
||||
{% if not build.is_complete %}
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Incomplete Build Outputs" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div class='btn-group' role='group'>
|
||||
<div id='build-output-toolbar'>
|
||||
<div class='button-toolbar container-fluid'>
|
||||
{% if build.active %}
|
||||
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
|
||||
<div class='btn-group'>
|
||||
<button class='btn btn-success' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
|
||||
<span class='fas fa-plus-circle'></span>
|
||||
</button>
|
||||
<!-- Build output actions -->
|
||||
<div class='btn-group'>
|
||||
<button id='output-options' class='btn btn-primary dropdown-toiggle' type='button' data-toggle='dropdown' title='{% trans "Output Actions" %}'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if build.incomplete_outputs %}
|
||||
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
|
||||
{% for item in build.incomplete_outputs %}
|
||||
{% include "build/allocation_card.html" with item=item tracked_items=build.has_tracked_bom_items %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
<strong>{% trans "Create a new build output" %}</strong><br>
|
||||
{% trans "No incomplete build outputs remain." %}<br>
|
||||
{% trans "Create a new build output using the button above" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class='panel panel-default panel-inventree panel-hidden' id='panel-completed'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Completed Build Outputs" %}
|
||||
@ -313,26 +320,75 @@ loadStockTable($("#build-stock-table"), {
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
|
||||
var buildInfo = {
|
||||
pk: {{ build.pk }},
|
||||
quantity: {{ build.quantity }},
|
||||
completed: {{ build.completed }},
|
||||
|
||||
// Get the list of BOM items required for this build
|
||||
inventreeGet(
|
||||
'{% url "api-bom-list" %}',
|
||||
{
|
||||
part: {{ build.part.pk }},
|
||||
sub_part_detail: true,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
|
||||
var build_info = {
|
||||
pk: {{ build.pk }},
|
||||
part: {{ build.part.pk }},
|
||||
quantity: {{ build.quantity }},
|
||||
bom_items: response,
|
||||
{% if build.take_from %}
|
||||
source_location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
{% if build.has_tracked_bom_items %}
|
||||
tracked_parts: true,
|
||||
{% else %}
|
||||
tracked_parts: false,
|
||||
{% endif %}
|
||||
};
|
||||
|
||||
{% for item in build.incomplete_outputs %}
|
||||
// Get the build output as a javascript object
|
||||
inventreeGet('{% url 'api-stock-detail' item.pk %}', {},
|
||||
{% if build.active %}
|
||||
loadBuildOutputTable(build_info);
|
||||
linkButtonsToSelection(
|
||||
'#build-output-table',
|
||||
[
|
||||
'#output-options',
|
||||
'#multi-output-complete',
|
||||
]
|
||||
);
|
||||
|
||||
$('#multi-output-complete').click(function() {
|
||||
var outputs = $('#build-output-table').bootstrapTable('getSelections');
|
||||
|
||||
completeBuildOutputs(
|
||||
build_info.pk,
|
||||
outputs,
|
||||
{
|
||||
success: function(response) {
|
||||
loadBuildOutputAllocationTable(buildInfo, response);
|
||||
success: function() {
|
||||
// Reload the "in progress" table
|
||||
$('#build-output-table').bootstrapTable('refresh');
|
||||
|
||||
// Reload the "completed" table
|
||||
$('#build-stock-table').bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if build.active and build.has_untracked_bom_items %}
|
||||
// Load allocation table for un-tracked parts
|
||||
loadBuildOutputAllocationTable(
|
||||
build_info,
|
||||
null,
|
||||
{
|
||||
search: true,
|
||||
}
|
||||
);
|
||||
{% endif %}
|
||||
}
|
||||
}
|
||||
);
|
||||
{% endfor %}
|
||||
|
||||
loadBuildTable($('#sub-build-table'), {
|
||||
url: '{% url "api-build-list" %}',
|
||||
@ -342,6 +398,7 @@ loadBuildTable($('#sub-build-table'), {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-build-attachment-list" %}',
|
||||
@ -416,11 +473,6 @@ $('#edit-notes').click(function() {
|
||||
});
|
||||
});
|
||||
|
||||
{% if build.has_untracked_bom_items %}
|
||||
// Load allocation table for un-tracked parts
|
||||
loadBuildOutputAllocationTable(buildInfo, null);
|
||||
{% endif %}
|
||||
|
||||
function reloadTable() {
|
||||
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||
}
|
||||
@ -471,6 +523,10 @@ $('#allocate-selected-items').click(function() {
|
||||
|
||||
var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections");
|
||||
|
||||
if (bom_items.length == 0) {
|
||||
bom_items = $("#allocation-table-untracked").bootstrapTable('getData');
|
||||
}
|
||||
|
||||
allocateStockToBuild(
|
||||
{{ build.pk }},
|
||||
{{ build.part.pk }},
|
||||
|
@ -1,10 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
<p>
|
||||
{% trans "Alter the quantity of stock allocated to the build output" %}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
@ -19,16 +19,25 @@
|
||||
{% if build.active %}
|
||||
<li class='list-group-item' title='{% trans "Allocate Stock" %}'>
|
||||
<a href='#' id='select-allocate' class='nav-toggle'>
|
||||
<span class='fas fa-tools sidebar-icon'></span>
|
||||
<span class='fas fa-tasks sidebar-icon'></span>
|
||||
{% trans "Allocate Stock" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class='list-group-item' title='{% trans "Build Outputs" %}'>
|
||||
{% if not build.is_complete %}
|
||||
<li class='list-group-item' title='{% trans "Pending Outputs" %}'>
|
||||
<a href='#' id='select-outputs' class='nav-toggle'>
|
||||
<span class='fas fa-box sidebar-icon'></span>
|
||||
{% trans "Build Outputs" %}
|
||||
<span class='fas fa-tools sidebar-icon'></span>
|
||||
{% trans "Pending Outputs" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class='list-group-item' title='{% trans "Completed Outputs" %}'>
|
||||
<a href='#' id='select-completed' class='nav-toggle'>
|
||||
<span class='fas fa-boxes sidebar-icon'></span>
|
||||
{% trans "Completed Outputs" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
@ -7,6 +7,7 @@ from django.urls import reverse
|
||||
|
||||
from part.models import Part
|
||||
from build.models import Build, BuildItem
|
||||
from stock.models import StockItem
|
||||
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
@ -37,6 +38,148 @@ class BuildAPITest(InvenTreeAPITestCase):
|
||||
super().setUp()
|
||||
|
||||
|
||||
class BuildCompleteTest(BuildAPITest):
|
||||
"""
|
||||
Unit testing for the build complete API endpoint
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.build = Build.objects.get(pk=1)
|
||||
|
||||
self.url = reverse('api-build-complete', kwargs={'pk': self.build.pk})
|
||||
|
||||
def test_invalid(self):
|
||||
"""
|
||||
Test with invalid data
|
||||
"""
|
||||
|
||||
# Test with an invalid build ID
|
||||
self.post(
|
||||
reverse('api-build-complete', kwargs={'pk': 99999}),
|
||||
{},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
data = self.post(self.url, {}, expected_code=400).data
|
||||
|
||||
self.assertIn("This field is required", str(data['outputs']))
|
||||
self.assertIn("This field is required", str(data['location']))
|
||||
|
||||
# Test with an invalid location
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"outputs": [],
|
||||
"location": 999999,
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn(
|
||||
"Invalid pk",
|
||||
str(data["location"])
|
||||
)
|
||||
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"outputs": [],
|
||||
"location": 1,
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn("A list of build outputs must be provided", str(data))
|
||||
|
||||
stock_item = StockItem.objects.create(
|
||||
part=self.build.part,
|
||||
quantity=100,
|
||||
)
|
||||
|
||||
post_data = {
|
||||
"outputs": [
|
||||
{
|
||||
"output": stock_item.pk,
|
||||
},
|
||||
],
|
||||
"location": 1,
|
||||
}
|
||||
|
||||
# Post with a stock item that does not match the build
|
||||
data = self.post(
|
||||
self.url,
|
||||
post_data,
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn(
|
||||
"Build output does not match the parent build",
|
||||
str(data["outputs"][0])
|
||||
)
|
||||
|
||||
# Now, ensure that the stock item *does* match the build
|
||||
stock_item.build = self.build
|
||||
stock_item.save()
|
||||
|
||||
data = self.post(
|
||||
self.url,
|
||||
post_data,
|
||||
expected_code=400,
|
||||
).data
|
||||
|
||||
self.assertIn(
|
||||
"This build output has already been completed",
|
||||
str(data["outputs"][0]["output"])
|
||||
)
|
||||
|
||||
def test_complete(self):
|
||||
"""
|
||||
Test build order completion
|
||||
"""
|
||||
|
||||
# We start without any outputs assigned against the build
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
||||
|
||||
# Create some more build outputs
|
||||
for ii in range(10):
|
||||
self.build.create_build_output(10)
|
||||
|
||||
# Check that we are in a known state
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 10)
|
||||
self.assertEqual(self.build.incomplete_count, 100)
|
||||
self.assertEqual(self.build.completed, 0)
|
||||
|
||||
# We shall complete 4 of these outputs
|
||||
outputs = self.build.incomplete_outputs[0:4]
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
"outputs": [{"output": output.pk} for output in outputs],
|
||||
"location": 1,
|
||||
"status": 50, # Item requires attention
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# There now should be 6 incomplete build outputs remaining
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 6)
|
||||
|
||||
# And there should be 4 completed outputs
|
||||
outputs = self.build.complete_outputs
|
||||
self.assertEqual(outputs.count(), 4)
|
||||
|
||||
for output in outputs:
|
||||
self.assertFalse(output.is_building)
|
||||
self.assertEqual(output.build, self.build)
|
||||
|
||||
self.build.refresh_from_db()
|
||||
self.assertEqual(self.build.completed, 40)
|
||||
|
||||
|
||||
class BuildAllocationTest(BuildAPITest):
|
||||
"""
|
||||
Unit tests for allocation of stock items against a build order.
|
||||
|
@ -339,11 +339,11 @@ class BuildTest(TestCase):
|
||||
self.assertTrue(self.build.isFullyAllocated(self.output_1))
|
||||
self.assertTrue(self.build.isFullyAllocated(self.output_2))
|
||||
|
||||
self.build.completeBuildOutput(self.output_1, None)
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
|
||||
self.assertFalse(self.build.can_complete)
|
||||
|
||||
self.build.completeBuildOutput(self.output_2, None)
|
||||
self.build.complete_build_output(self.output_2, None)
|
||||
|
||||
self.assertTrue(self.build.can_complete)
|
||||
|
||||
|
@ -15,7 +15,7 @@ from datetime import datetime, timedelta
|
||||
from .models import Build
|
||||
from stock.models import StockItem
|
||||
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
|
||||
|
||||
class BuildTestSimple(TestCase):
|
||||
@ -252,53 +252,6 @@ class TestBuildViews(TestCase):
|
||||
|
||||
self.assertIn(build.title, content)
|
||||
|
||||
def test_build_output_complete(self):
|
||||
"""
|
||||
Test the build output completion form
|
||||
"""
|
||||
|
||||
# Firstly, check that the build cannot be completed!
|
||||
self.assertFalse(self.build.can_complete)
|
||||
|
||||
url = reverse('build-output-complete', args=(1,))
|
||||
|
||||
# Test without confirmation
|
||||
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertFalse(data['form_valid'])
|
||||
|
||||
# Test with confirmation, valid location
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
'confirm': 1,
|
||||
'confirm_incomplete': 1,
|
||||
'location': 1,
|
||||
'output': self.output.pk,
|
||||
'stock_status': StockStatus.DAMAGED
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
|
||||
self.assertTrue(data['form_valid'])
|
||||
|
||||
# Now the build should be able to be completed
|
||||
self.build.refresh_from_db()
|
||||
self.assertTrue(self.build.can_complete)
|
||||
|
||||
# Test with confirmation, invalid location
|
||||
response = self.client.post(url, {'confirm': 1, 'location': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertFalse(data['form_valid'])
|
||||
|
||||
def test_build_cancel(self):
|
||||
""" Test the build cancellation form """
|
||||
|
||||
|
@ -11,7 +11,6 @@ build_detail_urls = [
|
||||
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
||||
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
||||
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
||||
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
|
||||
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
|
||||
|
||||
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||
|
@ -12,16 +12,17 @@ from django.forms import HiddenInput
|
||||
|
||||
from .models import Build
|
||||
from . import forms
|
||||
from stock.models import StockLocation, StockItem
|
||||
from stock.models import StockItem
|
||||
|
||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.helpers import str2bool, extract_serial_numbers
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
|
||||
|
||||
class BuildIndex(InvenTreeRoleMixin, ListView):
|
||||
""" View for displaying list of Builds
|
||||
"""
|
||||
View for displaying list of Builds
|
||||
"""
|
||||
model = Build
|
||||
template_name = 'build/index.html'
|
||||
@ -278,178 +279,10 @@ class BuildComplete(AjaxUpdateView):
|
||||
}
|
||||
|
||||
|
||||
class BuildOutputComplete(AjaxUpdateView):
|
||||
"""
|
||||
View to mark a particular build output as Complete.
|
||||
|
||||
- Notifies the user of which parts will be removed from stock.
|
||||
- Assignes (tracked) allocated items from stock to the build output
|
||||
- Deletes pending BuildItem objects
|
||||
"""
|
||||
|
||||
model = Build
|
||||
form_class = forms.CompleteBuildOutputForm
|
||||
context_object_name = "build"
|
||||
ajax_form_title = _("Complete Build Output")
|
||||
ajax_template_name = "build/complete_output.html"
|
||||
|
||||
def get_form(self):
|
||||
|
||||
build = self.get_object()
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
# Extract the build output object
|
||||
output = None
|
||||
output_id = form['output'].value()
|
||||
|
||||
try:
|
||||
output = StockItem.objects.get(pk=output_id)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
if output:
|
||||
if build.isFullyAllocated(output):
|
||||
form.fields['confirm_incomplete'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
def validate(self, build, form, **kwargs):
|
||||
"""
|
||||
Custom validation steps for the BuildOutputComplete" form
|
||||
"""
|
||||
|
||||
data = form.cleaned_data
|
||||
|
||||
output = data.get('output', None)
|
||||
|
||||
stock_status = data.get('stock_status', StockStatus.OK)
|
||||
|
||||
# Any "invalid" stock status defaults to OK
|
||||
try:
|
||||
stock_status = int(stock_status)
|
||||
except (ValueError):
|
||||
stock_status = StockStatus.OK
|
||||
|
||||
if int(stock_status) not in StockStatus.keys():
|
||||
form.add_error('stock_status', _('Invalid stock status value selected'))
|
||||
|
||||
if output:
|
||||
|
||||
quantity = data.get('quantity', None)
|
||||
|
||||
if quantity and quantity > output.quantity:
|
||||
form.add_error('quantity', _('Quantity to complete cannot exceed build output quantity'))
|
||||
|
||||
if not build.isFullyAllocated(output):
|
||||
confirm = str2bool(data.get('confirm_incomplete', False))
|
||||
|
||||
if not confirm:
|
||||
form.add_error('confirm_incomplete', _('Confirm completion of incomplete build'))
|
||||
|
||||
else:
|
||||
form.add_error(None, _('Build output must be specified'))
|
||||
|
||||
def get_initial(self):
|
||||
""" Get initial form data for the CompleteBuild form
|
||||
|
||||
- If the part being built has a default location, pre-select that location
|
||||
"""
|
||||
|
||||
initials = super().get_initial()
|
||||
build = self.get_object()
|
||||
|
||||
if build.part.default_location is not None:
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=build.part.default_location.id)
|
||||
initials['location'] = location
|
||||
except StockLocation.DoesNotExist:
|
||||
pass
|
||||
|
||||
output = self.get_param('output', None)
|
||||
|
||||
if output:
|
||||
try:
|
||||
output = StockItem.objects.get(pk=output)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
output = None
|
||||
|
||||
# Output has not been supplied? Try to "guess"
|
||||
if not output:
|
||||
|
||||
incomplete = build.get_build_outputs(complete=False)
|
||||
|
||||
if incomplete.count() == 1:
|
||||
output = incomplete[0]
|
||||
|
||||
if output is not None:
|
||||
initials['output'] = output
|
||||
|
||||
initials['location'] = build.destination
|
||||
|
||||
return initials
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Get context data for passing to the rendered form
|
||||
|
||||
- Build information is required
|
||||
"""
|
||||
|
||||
build = self.get_object()
|
||||
|
||||
context = {}
|
||||
|
||||
# Build object
|
||||
context['build'] = build
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
output = form['output'].value()
|
||||
|
||||
if output:
|
||||
try:
|
||||
output = StockItem.objects.get(pk=output)
|
||||
context['output'] = output
|
||||
context['fully_allocated'] = build.isFullyAllocated(output)
|
||||
context['allocated_parts'] = build.allocatedParts(output)
|
||||
context['unallocated_parts'] = build.unallocatedParts(output)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
return context
|
||||
|
||||
def save(self, build, form, **kwargs):
|
||||
|
||||
data = form.cleaned_data
|
||||
|
||||
location = data.get('location', None)
|
||||
output = data.get('output', None)
|
||||
stock_status = data.get('stock_status', StockStatus.OK)
|
||||
|
||||
# Any "invalid" stock status defaults to OK
|
||||
try:
|
||||
stock_status = int(stock_status)
|
||||
except (ValueError):
|
||||
stock_status = StockStatus.OK
|
||||
|
||||
# Complete the build output
|
||||
build.completeBuildOutput(
|
||||
output,
|
||||
self.request.user,
|
||||
location=location,
|
||||
status=stock_status,
|
||||
)
|
||||
|
||||
def get_data(self):
|
||||
""" Provide feedback data back to the form """
|
||||
return {
|
||||
'success': _('Build output completed')
|
||||
}
|
||||
|
||||
|
||||
class BuildDetail(InvenTreeRoleMixin, DetailView):
|
||||
""" Detail view of a single Build object. """
|
||||
"""
|
||||
Detail view of a single Build object.
|
||||
"""
|
||||
|
||||
model = Build
|
||||
template_name = 'build/detail.html'
|
||||
@ -477,7 +310,9 @@ class BuildDetail(InvenTreeRoleMixin, DetailView):
|
||||
|
||||
|
||||
class BuildDelete(AjaxDeleteView):
|
||||
""" View to delete a build """
|
||||
"""
|
||||
View to delete a build
|
||||
"""
|
||||
|
||||
model = Build
|
||||
ajax_template_name = 'build/delete_build.html'
|
||||
|
@ -1045,6 +1045,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': [int, MinValueValidator(1)]
|
||||
},
|
||||
|
||||
'SEARCH_SHOW_STOCK_LEVELS': {
|
||||
'name': _('Search Show Stock'),
|
||||
'description': _('Display stock levels in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_SHOW_QUANTITY_IN_FORMS': {
|
||||
'name': _('Show Quantity in Forms'),
|
||||
'description': _('Display available part quantity in some forms'),
|
||||
|
@ -1,7 +0,0 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
Are you sure you wish to delete this currency?
|
||||
|
||||
{% endblock %}
|
@ -5,7 +5,6 @@ JSON API for the Order app
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf.urls import url, include
|
||||
from django.db.models import Q, F
|
||||
|
||||
@ -13,7 +12,6 @@ from django_filters import rest_framework as rest_filters
|
||||
from rest_framework import generics
|
||||
from rest_framework import filters, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
@ -236,25 +234,15 @@ class POReceive(generics.CreateAPIView):
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# Pass the purchase order through to the serializer for validation
|
||||
context['order'] = self.get_order()
|
||||
try:
|
||||
context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
def get_order(self):
|
||||
"""
|
||||
Returns the PurchaseOrder associated with this API endpoint
|
||||
"""
|
||||
|
||||
pk = self.kwargs.get('pk', None)
|
||||
|
||||
try:
|
||||
order = PurchaseOrder.objects.get(pk=pk)
|
||||
except (PurchaseOrder.DoesNotExist, ValueError):
|
||||
raise ValidationError(_("Matching purchase order does not exist"))
|
||||
|
||||
return order
|
||||
|
||||
|
||||
class POLineItemFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
|
@ -9,7 +9,7 @@ from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField
|
||||
from InvenTree.fields import InvenTreeMoneyField
|
||||
|
||||
from InvenTree.helpers import clean_decimal
|
||||
|
||||
@ -19,7 +19,6 @@ import part.models
|
||||
|
||||
from .models import PurchaseOrder
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrderAllocation
|
||||
|
||||
|
||||
class IssuePurchaseOrderForm(HelperForm):
|
||||
@ -81,6 +80,8 @@ class AllocateSerialsToSalesOrderForm(forms.Form):
|
||||
"""
|
||||
Form for assigning stock to a sales order,
|
||||
by serial number lookup
|
||||
|
||||
TODO: Refactor this form / view to use the new API forms interface
|
||||
"""
|
||||
|
||||
line = forms.ModelChoiceField(
|
||||
@ -115,22 +116,6 @@ class AllocateSerialsToSalesOrderForm(forms.Form):
|
||||
]
|
||||
|
||||
|
||||
class EditSalesOrderAllocationForm(HelperForm):
|
||||
"""
|
||||
Form for editing a SalesOrderAllocation item
|
||||
"""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAllocation
|
||||
|
||||
fields = [
|
||||
'line',
|
||||
'item',
|
||||
'quantity']
|
||||
|
||||
|
||||
class OrderMatchItemForm(MatchItemForm):
|
||||
""" Override MatchItemForm fields """
|
||||
|
||||
|
@ -36,31 +36,39 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<p>{{ order.description }}{% include "clip.html"%}</p>
|
||||
<div class='btn-row'>
|
||||
<div class='btn-group action-buttons' role='group'>
|
||||
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'>
|
||||
<span class='fas fa-print'></span>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
|
||||
<span class='fas fa-file-download'></span>
|
||||
<!-- Printing options -->
|
||||
<div class='btn-group'>
|
||||
<button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print purchase order report" %}</a></li>
|
||||
<li><a href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% if roles.purchase_order.change %}
|
||||
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
|
||||
<span class='fas fa-edit icon-green'></span>
|
||||
<!-- order actions -->
|
||||
<div class='btn-group'>
|
||||
<button id='order-options' title='{% trans "Order actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
|
||||
{% if order.can_cancel %}
|
||||
<li><a href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
|
||||
<button type='button' class='btn btn-default' id='place-order' title='{% trans "Place order" %}'>
|
||||
<span class='fas fa-paper-plane icon-blue'></span>
|
||||
<span class='fas fa-shopping-cart icon-blue'></span>
|
||||
</button>
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span>
|
||||
<span class='fas fa-sign-in-alt icon-blue'></span>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'>
|
||||
<span class='fas fa-check-circle'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if order.can_cancel %}
|
||||
<button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
|
||||
<span class='fas fa-times-circle icon-red'></span>
|
||||
<span class='fas fa-check-circle icon-green'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
@ -47,30 +47,39 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<p>{{ order.description }}{% include "clip.html"%}</p>
|
||||
<div class='btn-row'>
|
||||
<div class='btn-group action-buttons'>
|
||||
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'>
|
||||
<span class='fas fa-print'></span>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
|
||||
<span class='fas fa-file-download'></span>
|
||||
<!-- Printing actions -->
|
||||
<div class='btn-group'>
|
||||
<button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print sales order report" %}</a></li>
|
||||
<li><a href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
|
||||
<!--
|
||||
<li><a href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li>
|
||||
-->
|
||||
</ul>
|
||||
</div>
|
||||
{% if roles.sales_order.change %}
|
||||
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
|
||||
<span class='fas fa-edit icon-green'></span>
|
||||
<!-- Order actions -->
|
||||
<div class='btn-group'>
|
||||
<button id='order-options' title='{% trans "Order actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
<li><a href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
<button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'>
|
||||
<span class='fas fa-paper-plane icon-blue'></span>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
|
||||
<span class='fas fa-times-circle icon-red'></span>
|
||||
<span class='fas fa-truck icon-blue'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<!--
|
||||
<button type='button' disabled='' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
|
||||
<span class='fas fa-clipboard-list'></span>
|
||||
</button>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -203,6 +203,23 @@ class PurchaseOrderTest(OrderTest):
|
||||
# And if we try to access the detail view again, it has gone
|
||||
response = self.get(url, expected_code=404)
|
||||
|
||||
def test_po_create(self):
|
||||
"""
|
||||
Test that we can create a new PurchaseOrder via the API
|
||||
"""
|
||||
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
self.post(
|
||||
reverse('api-po-list'),
|
||||
{
|
||||
'reference': '12345678',
|
||||
'supplier': 1,
|
||||
'description': 'A test purchase order',
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
|
||||
class PurchaseOrderReceiveTest(OrderTest):
|
||||
"""
|
||||
@ -607,3 +624,20 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
# And the resource should no longer be available
|
||||
response = self.get(url, expected_code=404)
|
||||
|
||||
def test_so_create(self):
|
||||
"""
|
||||
Test that we can create a new SalesOrder via the API
|
||||
"""
|
||||
|
||||
self.assignRole('sales_order.add')
|
||||
|
||||
self.post(
|
||||
reverse('api-so-list'),
|
||||
{
|
||||
'reference': '1234566778',
|
||||
'customer': 4,
|
||||
'description': 'A test sales order',
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
@ -18,7 +18,7 @@ import common.models
|
||||
from common.forms import MatchItemForm
|
||||
|
||||
from .models import Part, PartCategory, PartRelated
|
||||
from .models import PartParameterTemplate, PartParameter
|
||||
from .models import PartParameterTemplate
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
@ -188,18 +188,6 @@ class EditPartParameterTemplateForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class EditPartParameterForm(HelperForm):
|
||||
""" Form for editing a PartParameter object """
|
||||
|
||||
class Meta:
|
||||
model = PartParameter
|
||||
fields = [
|
||||
'part',
|
||||
'template',
|
||||
'data'
|
||||
]
|
||||
|
||||
|
||||
class EditCategoryForm(HelperForm):
|
||||
""" Form for editing a PartCategory object """
|
||||
|
||||
|
@ -16,8 +16,6 @@ from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.fields import DatePickerFormField
|
||||
|
||||
from report.models import TestReport
|
||||
|
||||
from part.models import Part
|
||||
|
||||
from .models import StockLocation, StockItem, StockItemTracking
|
||||
@ -26,6 +24,8 @@ from .models import StockLocation, StockItem, StockItemTracking
|
||||
class AssignStockItemToCustomerForm(HelperForm):
|
||||
"""
|
||||
Form for manually assigning a StockItem to a Customer
|
||||
|
||||
TODO: This could be a simple API driven form!
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@ -38,6 +38,8 @@ class AssignStockItemToCustomerForm(HelperForm):
|
||||
class ReturnStockItemForm(HelperForm):
|
||||
"""
|
||||
Form for manually returning a StockItem into stock
|
||||
|
||||
TODO: This could be a simple API driven form!
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@ -48,7 +50,11 @@ class ReturnStockItemForm(HelperForm):
|
||||
|
||||
|
||||
class EditStockLocationForm(HelperForm):
|
||||
""" Form for editing a StockLocation """
|
||||
"""
|
||||
Form for editing a StockLocation
|
||||
|
||||
TODO: Migrate this form to the modern API forms interface
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = StockLocation
|
||||
@ -63,6 +69,8 @@ class EditStockLocationForm(HelperForm):
|
||||
class ConvertStockItemForm(HelperForm):
|
||||
"""
|
||||
Form for converting a StockItem to a variant of its current part.
|
||||
|
||||
TODO: Migrate this form to the modern API forms interface
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@ -73,7 +81,11 @@ class ConvertStockItemForm(HelperForm):
|
||||
|
||||
|
||||
class CreateStockItemForm(HelperForm):
|
||||
""" Form for creating a new StockItem """
|
||||
"""
|
||||
Form for creating a new StockItem
|
||||
|
||||
TODO: Migrate this form to the modern API forms interface
|
||||
"""
|
||||
|
||||
expiry_date = DatePickerFormField(
|
||||
label=_('Expiry Date'),
|
||||
@ -129,7 +141,11 @@ class CreateStockItemForm(HelperForm):
|
||||
|
||||
|
||||
class SerializeStockForm(HelperForm):
|
||||
""" Form for serializing a StockItem. """
|
||||
"""
|
||||
Form for serializing a StockItem.
|
||||
|
||||
TODO: Migrate this form to the modern API forms interface
|
||||
"""
|
||||
|
||||
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination for serialized stock (by default, will remain in current location)'))
|
||||
|
||||
@ -160,73 +176,11 @@ class SerializeStockForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class StockItemLabelSelectForm(HelperForm):
|
||||
""" Form for selecting a label template for a StockItem """
|
||||
|
||||
label = forms.ChoiceField(
|
||||
label=_('Label'),
|
||||
help_text=_('Select test report template')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
fields = [
|
||||
'label',
|
||||
]
|
||||
|
||||
def get_label_choices(self, labels):
|
||||
|
||||
choices = []
|
||||
|
||||
if len(labels) > 0:
|
||||
for label in labels:
|
||||
choices.append((label.pk, label))
|
||||
|
||||
return choices
|
||||
|
||||
def __init__(self, labels, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['label'].choices = self.get_label_choices(labels)
|
||||
|
||||
|
||||
class TestReportFormatForm(HelperForm):
|
||||
""" Form for selection a test report template """
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
fields = [
|
||||
'template',
|
||||
]
|
||||
|
||||
def __init__(self, stock_item, *args, **kwargs):
|
||||
self.stock_item = stock_item
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['template'].choices = self.get_template_choices()
|
||||
|
||||
def get_template_choices(self):
|
||||
"""
|
||||
Generate a list of of TestReport options for the StockItem
|
||||
"""
|
||||
|
||||
choices = []
|
||||
|
||||
templates = TestReport.objects.filter(enabled=True)
|
||||
|
||||
for template in templates:
|
||||
if template.enabled and template.matches_stock_item(self.stock_item):
|
||||
choices.append((template.pk, template))
|
||||
|
||||
return choices
|
||||
|
||||
template = forms.ChoiceField(label=_('Template'), help_text=_('Select test report template'))
|
||||
|
||||
|
||||
class InstallStockForm(HelperForm):
|
||||
"""
|
||||
Form for manually installing a stock item into another stock item
|
||||
|
||||
TODO: Migrate this form to the modern API forms interface
|
||||
"""
|
||||
|
||||
part = forms.ModelChoiceField(
|
||||
@ -275,6 +229,8 @@ class InstallStockForm(HelperForm):
|
||||
class UninstallStockForm(forms.ModelForm):
|
||||
"""
|
||||
Form for uninstalling a stock item which is installed in another item.
|
||||
|
||||
TODO: Migrate this form to the modern API forms interface
|
||||
"""
|
||||
|
||||
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items'))
|
||||
@ -301,6 +257,8 @@ class EditStockItemForm(HelperForm):
|
||||
location - Must be updated in a 'move' transaction
|
||||
quantity - Must be updated in a 'stocktake' transaction
|
||||
part - Cannot be edited after creation
|
||||
|
||||
TODO: Migrate this form to the modern API forms interface
|
||||
"""
|
||||
|
||||
expiry_date = DatePickerFormField(
|
||||
|
@ -1,50 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<input type='hidden' name='stock_action' value='{{ stock_action }}'/>
|
||||
|
||||
<table class='table table-condensed table-striped' id='stock-table'>
|
||||
<tr>
|
||||
<th>{% trans "Stock Item" %}</th>
|
||||
<th>{% trans "Location" %}</th>
|
||||
<th>{% trans "Quantity" %}</th>
|
||||
{% if edit_quantity %}
|
||||
<th>{{ stock_action_title }}</th>
|
||||
{% endif %}
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for item in stock_items %}
|
||||
<tr id='stock-row-{{ item.id }}' class='error'>
|
||||
<td>{% include "hover_image.html" with image=item.part.image hover=True %}
|
||||
{{ item.part.full_name }} <small><em>{{ item.part.description }}</em></small></td>
|
||||
<td>{{ item.location.pathstring }}</td>
|
||||
<td>{% decimal item.quantity %}</td>
|
||||
<td>
|
||||
{% if edit_quantity %}
|
||||
<input class='numberinput'
|
||||
min='0'
|
||||
{% if stock_action == 'take' or stock_action == 'move' %} max='{{ item.quantity }}' {% endif %}
|
||||
value='{% decimal item.new_quantity %}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/>
|
||||
{% if item.error %}
|
||||
<br><span class='help-inline'>{{ item.error }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<input type='hidden' name='stock-id-{{ item.id }}' value='{{ item.new_quantity }}'/>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><button class='btn btn-default btn-remove' onclick='removeStockRow()' id='del-{{ item.id }}' title='{% trans "Remove item" %}' type='button'><span row='stock-row-{{ item.id }}' class='fas fa-trash-alt icon-red'></span></button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% crispy form %}
|
||||
|
||||
</form>
|
@ -1 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
@ -12,12 +12,14 @@
|
||||
<hr>
|
||||
|
||||
<div class='col-sm-3' id='item-panel'>
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<ul class='list-group' id='action-item-list'>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class='col-sm-9' id='details-panel'>
|
||||
<ul class='list-group' id='detail-item-list'>
|
||||
<li class='list-group-item'>
|
||||
<li class='list-group-item panel panel-default panel-inventree'>
|
||||
<div class='container'>
|
||||
<img class='index-bg' src='{% static "img/inventree.png" %}'>
|
||||
</div>
|
||||
@ -54,7 +56,7 @@ function addHeaderAction(label, title, icon, options) {
|
||||
|
||||
// Add a detail item to the detail item-panel
|
||||
$("#detail-item-list").append(
|
||||
`<li class='list-group-item' id='detail-${label}'>
|
||||
`<li class='list-group-item panel panel-default panel-inventree' id='detail-${label}'>
|
||||
<h4>${title}</h4>
|
||||
<table class='table table-condensed table-striped' id='table-${label}'></table>
|
||||
</li>`
|
||||
|
@ -26,12 +26,14 @@
|
||||
{% endif %}
|
||||
|
||||
<div class='col-sm-3' id='item-panel'>
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<ul class='list-group' id='search-item-list'>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class='col-sm-9' id='details-panel'>
|
||||
<ul class='list-group' id='search-result-list'>
|
||||
<li class='list-group-item'>
|
||||
<li class='list-group-item panel panel-default panel-inventree'>
|
||||
<div class='container'>
|
||||
<img class='index-bg' src='{% static "img/inventree.png" %}'>
|
||||
</div>
|
||||
@ -67,7 +69,7 @@
|
||||
|
||||
// Add a results table
|
||||
$('#search-result-list').append(
|
||||
`<li class='list-group-item' id='search-result-${label}'>
|
||||
`<li class='list-group-item panel panel-default panel-inventree' id='search-result-${label}'>
|
||||
<h4>${title}</h4>
|
||||
<table class='table table-condensed table-striped' id='table-${label}'></table>
|
||||
</li>`
|
||||
|
@ -10,12 +10,12 @@
|
||||
</li>
|
||||
|
||||
<li class='list-group-item'>
|
||||
<strong>{% trans "User Settings" %}</strong>
|
||||
<span class='fas fa-user'></span> <strong>{% trans "User Settings" %}</strong>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item' title='{% trans "Account" %}'>
|
||||
<a href='#' class='nav-toggle' id='select-account'>
|
||||
<span class='fas fa-user'></span> {% trans "Account" %}
|
||||
<span class='fas fa-user-cog'></span> {% trans "Account" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
{% if user.is_staff %}
|
||||
|
||||
<li class='list-group-item'>
|
||||
<strong>{% trans "InvenTree Settings" %}</strong>
|
||||
<span class='fas fa-cogs'></span> <strong>{% trans "Global Settings" %}</strong>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item' title='{% trans "Server" %}'>
|
||||
|
@ -16,6 +16,7 @@
|
||||
{% include "InvenTree/settings/header.html" %}
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -1,7 +0,0 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
{% trans "Are you sure you want to delete this attachment?" %}
|
||||
<br>
|
||||
{% endblock %}
|
@ -143,7 +143,6 @@
|
||||
|
||||
<!-- general InvenTree -->
|
||||
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script>
|
||||
|
||||
<!-- dynamic javascript templates -->
|
||||
<script type='text/javascript' src="{% url 'inventree.js' %}"></script>
|
||||
|
@ -1,23 +0,0 @@
|
||||
{% block collapse_preamble %}
|
||||
{% endblock %}
|
||||
<div class='panel-group'>
|
||||
<div class='panel panel-default'>
|
||||
<div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<div class='panel-title'>
|
||||
<a data-toggle='collapse' href="#collapse-item-{{ collapse_id }}">{% block collapse_title %}Title{% endblock %}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% block collapse_heading %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-collapse collapse' id='collapse-item-{{ collapse_id }}'>
|
||||
<div class='panel-body'>
|
||||
{% block collapse_content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,19 +0,0 @@
|
||||
{% block collapse_preamble %}
|
||||
{% endblock %}
|
||||
<div class='panel-group panel-index'>
|
||||
<div class='panel panel-default'>
|
||||
<div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}>
|
||||
<div class='panel-title'>
|
||||
<a data-toggle='collapse' href="#collapse-item-{{ collapse_id }}">{% block collapse_title %}Title{% endblock %}</a>
|
||||
</div>
|
||||
{% block collapse_heading %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div class='panel-collapse collapse' id='collapse-item-{{ collapse_id }}'>
|
||||
<div class='panel-body'>
|
||||
{% block collapse_content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -140,11 +140,13 @@ function inventreeDocReady() {
|
||||
offset: 0
|
||||
},
|
||||
success: function(data) {
|
||||
|
||||
var transformed = $.map(data.results, function(el) {
|
||||
return {
|
||||
label: el.full_name,
|
||||
id: el.pk,
|
||||
thumbnail: el.thumbnail
|
||||
thumbnail: el.thumbnail,
|
||||
data: el,
|
||||
};
|
||||
});
|
||||
response(transformed);
|
||||
@ -164,7 +166,18 @@ function inventreeDocReady() {
|
||||
html += `'> `;
|
||||
html += item.label;
|
||||
|
||||
html += '</span></a>';
|
||||
html += '</span>';
|
||||
|
||||
if (user_settings.SEARCH_SHOW_STOCK_LEVELS) {
|
||||
html += partStockLabel(
|
||||
item.data,
|
||||
{
|
||||
label_class: 'label-right',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
html += '</a>';
|
||||
|
||||
return $('<li>').append(html).appendTo(ul);
|
||||
};
|
||||
@ -290,3 +303,8 @@ function loadBrandIcon(element, name) {
|
||||
element.addClass('fab fa-' + name);
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function to determine if an element exists
|
||||
$.fn.exists = function() {
|
||||
return this.length !== 0;
|
||||
};
|
||||
|
@ -3,6 +3,9 @@
|
||||
|
||||
/* exported
|
||||
attachNavCallbacks,
|
||||
enableNavbar,
|
||||
initNavTree,
|
||||
loadTree,
|
||||
onPanelLoad,
|
||||
*/
|
||||
|
||||
@ -113,3 +116,253 @@ function onPanelLoad(panel, callback) {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
function loadTree(url, tree, options={}) {
|
||||
/* Load the side-nav tree view
|
||||
|
||||
Args:
|
||||
url: URL to request tree data
|
||||
tree: html ref to treeview
|
||||
options:
|
||||
data: data object to pass to the AJAX request
|
||||
selected: ID of currently selected item
|
||||
name: name of the tree
|
||||
*/
|
||||
|
||||
var data = {};
|
||||
|
||||
if (options.data) {
|
||||
data = options.data;
|
||||
}
|
||||
|
||||
var key = 'inventree-sidenav-items-';
|
||||
|
||||
if (options.name) {
|
||||
key += options.name;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'get',
|
||||
dataType: 'json',
|
||||
data: data,
|
||||
success: function(response) {
|
||||
if (response.tree) {
|
||||
$(tree).treeview({
|
||||
data: response.tree,
|
||||
enableLinks: true,
|
||||
showTags: true,
|
||||
});
|
||||
|
||||
if (localStorage.getItem(key)) {
|
||||
var saved_exp = localStorage.getItem(key).split(',');
|
||||
|
||||
// Automatically expand the desired notes
|
||||
for (var q = 0; q < saved_exp.length; q++) {
|
||||
$(tree).treeview('expandNode', parseInt(saved_exp[q]));
|
||||
}
|
||||
}
|
||||
|
||||
// Setup a callback whenever a node is toggled
|
||||
$(tree).on('nodeExpanded nodeCollapsed', function(event, data) {
|
||||
|
||||
// Record the entire list of expanded items
|
||||
var expanded = $(tree).treeview('getExpanded');
|
||||
|
||||
var exp = [];
|
||||
|
||||
for (var i = 0; i < expanded.length; i++) {
|
||||
exp.push(expanded[i].nodeId);
|
||||
}
|
||||
|
||||
// Save the expanded nodes
|
||||
localStorage.setItem(key, exp);
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(xhr, ajaxOptions, thrownError) {
|
||||
// TODO
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize navigation tree display
|
||||
*/
|
||||
function initNavTree(options) {
|
||||
|
||||
var resize = true;
|
||||
|
||||
if ('resize' in options) {
|
||||
resize = options.resize;
|
||||
}
|
||||
|
||||
var label = options.label || 'nav';
|
||||
|
||||
var stateLabel = `${label}-tree-state`;
|
||||
var widthLabel = `${label}-tree-width`;
|
||||
|
||||
var treeId = options.treeId || '#sidenav-left';
|
||||
var toggleId = options.toggleId;
|
||||
|
||||
// Initially hide the tree
|
||||
$(treeId).animate({
|
||||
width: '0px',
|
||||
}, 0, function() {
|
||||
|
||||
if (resize) {
|
||||
$(treeId).resizable({
|
||||
minWidth: '0px',
|
||||
maxWidth: '500px',
|
||||
handles: 'e, se',
|
||||
grid: [5, 5],
|
||||
stop: function(event, ui) {
|
||||
var width = Math.round(ui.element.width());
|
||||
|
||||
if (width < 75) {
|
||||
$(treeId).animate({
|
||||
width: '0px'
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
} else {
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
localStorage.setItem(widthLabel, `${width}px`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var state = localStorage.getItem(stateLabel);
|
||||
var width = localStorage.getItem(widthLabel) || '300px';
|
||||
|
||||
if (state && state == 'open') {
|
||||
|
||||
$(treeId).animate({
|
||||
width: width,
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Register callback for 'toggle' button
|
||||
if (toggleId) {
|
||||
|
||||
$(toggleId).click(function() {
|
||||
|
||||
var state = localStorage.getItem(stateLabel) || 'closed';
|
||||
var width = localStorage.getItem(widthLabel) || '300px';
|
||||
|
||||
if (state == 'open') {
|
||||
$(treeId).animate({
|
||||
width: '0px'
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
} else {
|
||||
$(treeId).animate({
|
||||
width: width,
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle left-hand icon menubar display
|
||||
*/
|
||||
function enableNavbar(options) {
|
||||
|
||||
var resize = true;
|
||||
|
||||
if ('resize' in options) {
|
||||
resize = options.resize;
|
||||
}
|
||||
|
||||
var label = options.label || 'nav';
|
||||
|
||||
label = `navbar-${label}`;
|
||||
|
||||
var stateLabel = `${label}-state`;
|
||||
var widthLabel = `${label}-width`;
|
||||
|
||||
var navId = options.navId || '#sidenav-right';
|
||||
|
||||
var toggleId = options.toggleId;
|
||||
|
||||
// Extract the saved width for this element
|
||||
$(navId).animate({
|
||||
'width': '45px',
|
||||
'min-width': '45px',
|
||||
'display': 'block',
|
||||
}, 50, function() {
|
||||
|
||||
// Make the navbar resizable
|
||||
if (resize) {
|
||||
$(navId).resizable({
|
||||
minWidth: options.minWidth || '100px',
|
||||
maxWidth: options.maxWidth || '500px',
|
||||
handles: 'e, se',
|
||||
grid: [5, 5],
|
||||
stop: function(event, ui) {
|
||||
// Record the new width
|
||||
var width = Math.round(ui.element.width());
|
||||
|
||||
// Reasonably narrow? Just close it!
|
||||
if (width <= 75) {
|
||||
$(navId).animate({
|
||||
width: '45px'
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
} else {
|
||||
localStorage.setItem(widthLabel, `${width}px`);
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var state = localStorage.getItem(stateLabel);
|
||||
|
||||
var width = localStorage.getItem(widthLabel) || '250px';
|
||||
|
||||
if (state && state == 'open') {
|
||||
|
||||
$(navId).animate({
|
||||
width: width
|
||||
}, 100);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Register callback for 'toggle' button
|
||||
if (toggleId) {
|
||||
|
||||
$(toggleId).click(function() {
|
||||
|
||||
var state = localStorage.getItem(stateLabel) || 'closed';
|
||||
var width = localStorage.getItem(widthLabel) || '250px';
|
||||
|
||||
if (state == 'open') {
|
||||
$(navId).animate({
|
||||
width: '45px',
|
||||
minWidth: '45px',
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'closed');
|
||||
|
||||
} else {
|
||||
|
||||
$(navId).animate({
|
||||
'width': width
|
||||
}, 50);
|
||||
|
||||
localStorage.setItem(stateLabel, 'open');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -24,7 +24,7 @@
|
||||
loadAllocationTable,
|
||||
loadBuildOrderAllocationTable,
|
||||
loadBuildOutputAllocationTable,
|
||||
loadBuildPartsTable,
|
||||
loadBuildOutputTable,
|
||||
loadBuildTable,
|
||||
*/
|
||||
|
||||
@ -108,126 +108,56 @@ function newBuildOrder(options={}) {
|
||||
}
|
||||
|
||||
|
||||
function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
/* Generate action buttons for a build output.
|
||||
/*
|
||||
* Construct a set of output buttons for a particular build output
|
||||
*/
|
||||
|
||||
var buildId = buildInfo.pk;
|
||||
var partId = buildInfo.part;
|
||||
|
||||
var outputId = 'untracked';
|
||||
|
||||
if (output) {
|
||||
outputId = output.pk;
|
||||
}
|
||||
|
||||
var panel = `#allocation-panel-${outputId}`;
|
||||
|
||||
function reloadTable() {
|
||||
$(panel).find(`#allocation-table-${outputId}`).bootstrapTable('refresh');
|
||||
}
|
||||
|
||||
// Find the div where the buttons will be displayed
|
||||
var buildActions = $(panel).find(`#output-actions-${outputId}`);
|
||||
function makeBuildOutputButtons(output_id, build_info, options={}) {
|
||||
|
||||
var html = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
if (lines > 0) {
|
||||
html += makeIconButton(
|
||||
'fa-sign-in-alt icon-blue', 'button-output-auto', outputId,
|
||||
'{% trans "Allocate stock items to this build output" %}',
|
||||
);
|
||||
}
|
||||
// Tracked parts? Must be individually allocated
|
||||
if (build_info.tracked_parts) {
|
||||
|
||||
if (lines > 0) {
|
||||
// Add a button to "cancel" the particular build output (unallocate)
|
||||
// Add a button to allocate stock against this build output
|
||||
html += makeIconButton(
|
||||
'fa-minus-circle icon-red', 'button-output-unallocate', outputId,
|
||||
'fa-sign-in-alt icon-blue',
|
||||
'button-output-allocate',
|
||||
output_id,
|
||||
'{% trans "Allocate stock items to this build output" %}',
|
||||
{
|
||||
disabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add a button to unallocate stock from this build output
|
||||
html += makeIconButton(
|
||||
'fa-minus-circle icon-red',
|
||||
'button-output-unallocate',
|
||||
output_id,
|
||||
'{% trans "Unallocate stock from build output" %}',
|
||||
);
|
||||
}
|
||||
|
||||
if (output) {
|
||||
|
||||
// Add a button to "complete" the particular build output
|
||||
// Add a button to "complete" this build output
|
||||
html += makeIconButton(
|
||||
'fa-check icon-green', 'button-output-complete', outputId,
|
||||
'fa-check-circle icon-green',
|
||||
'button-output-complete',
|
||||
output_id,
|
||||
'{% trans "Complete build output" %}',
|
||||
{
|
||||
// disabled: true
|
||||
}
|
||||
);
|
||||
|
||||
// Add a button to "delete" the particular build output
|
||||
// Add a button to "delete" this build output
|
||||
html += makeIconButton(
|
||||
'fa-trash-alt icon-red', 'button-output-delete', outputId,
|
||||
'fa-trash-alt icon-red',
|
||||
'button-output-delete',
|
||||
output_id,
|
||||
'{% trans "Delete build output" %}',
|
||||
);
|
||||
|
||||
// TODO - Add a button to "destroy" the particular build output (mark as damaged, scrap)
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
|
||||
buildActions.html(html);
|
||||
|
||||
// Add callbacks for the buttons
|
||||
$(panel).find(`#button-output-auto-${outputId}`).click(function() {
|
||||
|
||||
var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData');
|
||||
|
||||
// Launch modal dialog to perform auto-allocation
|
||||
allocateStockToBuild(
|
||||
buildId,
|
||||
partId,
|
||||
bom_items,
|
||||
{
|
||||
source_location: buildInfo.source_location,
|
||||
output: outputId,
|
||||
success: reloadTable,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$(panel).find(`#button-output-complete-${outputId}`).click(function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(
|
||||
`/build/${buildId}/complete-output/`,
|
||||
{
|
||||
data: {
|
||||
output: pk,
|
||||
},
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$(panel).find(`#button-output-unallocate-${outputId}`).click(function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
unallocateStock(buildId, {
|
||||
output: pk,
|
||||
table: table,
|
||||
});
|
||||
});
|
||||
|
||||
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(
|
||||
`/build/${buildId}/delete-output/`,
|
||||
{
|
||||
reload: true,
|
||||
data: {
|
||||
output: pk
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -270,14 +200,160 @@ function unallocateStock(build_id, options={}) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function loadBuildOrderAllocationTable(table, options={}) {
|
||||
/**
|
||||
* Launch a modal form to complete selected build outputs
|
||||
*/
|
||||
function completeBuildOutputs(build_id, outputs, options={}) {
|
||||
|
||||
if (outputs.length == 0) {
|
||||
showAlertDialog(
|
||||
'{% trans "Select Build Outputs" %}',
|
||||
'{% trans "At least one build output must be selected" %}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render a single build output (StockItem)
|
||||
function renderBuildOutput(output, opts={}) {
|
||||
var pk = output.pk;
|
||||
|
||||
var output_html = imageHoverIcon(output.part_detail.thumbnail);
|
||||
|
||||
if (output.quantity == 1 && output.serial) {
|
||||
output_html += `{% trans "Serial Number" %}: ${output.serial}`;
|
||||
} else {
|
||||
output_html += `{% trans "Quantity" %}: ${output.quantity}`;
|
||||
}
|
||||
|
||||
var buttons = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}');
|
||||
|
||||
buttons += '</div>';
|
||||
|
||||
var field = constructField(
|
||||
`outputs_output_${pk}`,
|
||||
{
|
||||
type: 'raw',
|
||||
html: output_html,
|
||||
},
|
||||
{
|
||||
hideLabels: true,
|
||||
}
|
||||
);
|
||||
|
||||
var html = `
|
||||
<tr id='output_row_${pk}'>
|
||||
<td>${field}</td>
|
||||
<td>${output.part_detail.full_name}</td>
|
||||
<td>${buttons}</td>
|
||||
</tr>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Construct table entries
|
||||
var table_entries = '';
|
||||
|
||||
outputs.forEach(function(output) {
|
||||
table_entries += renderBuildOutput(output);
|
||||
});
|
||||
|
||||
var html = `
|
||||
<table class='table table-striped table-condensed' id='build-complete-table'>
|
||||
<thead>
|
||||
<th colspan='2'>{% trans "Output" %}</th>
|
||||
<th><!-- Actions --></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
${table_entries}
|
||||
</tbody>
|
||||
</table>`;
|
||||
|
||||
constructForm(`/api/build/${build_id}/complete/`, {
|
||||
method: 'POST',
|
||||
preFormContent: html,
|
||||
fields: {
|
||||
status: {},
|
||||
location: {},
|
||||
},
|
||||
confirm: true,
|
||||
title: '{% trans "Complete Build Outputs" %}',
|
||||
afterRender: function(fields, opts) {
|
||||
// Setup callbacks to remove outputs
|
||||
$(opts.modal).find('.button-row-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
$(opts.modal).find(`#output_row_${pk}`).remove();
|
||||
});
|
||||
},
|
||||
onSubmit: function(fields, opts) {
|
||||
|
||||
// Extract data elements from the form
|
||||
var data = {
|
||||
outputs: [],
|
||||
status: getFormFieldValue('status', {}, opts),
|
||||
location: getFormFieldValue('location', {}, opts),
|
||||
};
|
||||
|
||||
var output_pk_values = [];
|
||||
|
||||
outputs.forEach(function(output) {
|
||||
var pk = output.pk;
|
||||
|
||||
var row = $(opts.modal).find(`#output_row_${pk}`);
|
||||
|
||||
if (row.exists()) {
|
||||
data.outputs.push({
|
||||
output: pk,
|
||||
});
|
||||
output_pk_values.push(pk);
|
||||
}
|
||||
});
|
||||
|
||||
// Provide list of nested values
|
||||
opts.nested = {
|
||||
'outputs': output_pk_values,
|
||||
};
|
||||
|
||||
inventreePut(
|
||||
opts.url,
|
||||
data,
|
||||
{
|
||||
method: 'POST',
|
||||
success: function(response) {
|
||||
// Hide the modal
|
||||
$(opts.modal).modal('hide');
|
||||
|
||||
if (options.success) {
|
||||
options.success(response);
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
switch (xhr.status) {
|
||||
case 400:
|
||||
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||
break;
|
||||
default:
|
||||
$(opts.modal).modal('hide');
|
||||
showApiError(xhr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load a table showing all the BuildOrder allocations for a given part
|
||||
*/
|
||||
function loadBuildOrderAllocationTable(table, options={}) {
|
||||
|
||||
options.params['part_detail'] = true;
|
||||
options.params['build_detail'] = true;
|
||||
@ -357,9 +433,246 @@ function loadBuildOrderAllocationTable(table, options={}) {
|
||||
}
|
||||
|
||||
|
||||
function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
/*
|
||||
* Load the "allocation table" for a particular build output.
|
||||
* Display a "build output" table for a particular build.
|
||||
*
|
||||
* This displays a list of "active" (i.e. "in production") build outputs for a given build
|
||||
*
|
||||
*/
|
||||
function loadBuildOutputTable(build_info, options={}) {
|
||||
|
||||
var table = options.table || '#build-output-table';
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
// Mandatory query filters
|
||||
params.part_detail = true;
|
||||
params.is_building = true;
|
||||
params.build = build_info.pk;
|
||||
|
||||
// Construct a list of "tracked" BOM items
|
||||
var tracked_bom_items = [];
|
||||
|
||||
var has_tracked_items = false;
|
||||
|
||||
build_info.bom_items.forEach(function(bom_item) {
|
||||
if (bom_item.sub_part_detail.trackable) {
|
||||
tracked_bom_items.push(bom_item);
|
||||
has_tracked_items = true;
|
||||
};
|
||||
});
|
||||
|
||||
var filters = {};
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
// TODO: Initialize filter list
|
||||
|
||||
function setupBuildOutputButtonCallbacks() {
|
||||
|
||||
// Callback for the "allocate" button
|
||||
$(table).find('.button-output-allocate').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
// Find the "allocation" sub-table associated with this output
|
||||
var subtable = $(`#output-sub-table-${pk}`);
|
||||
|
||||
if (subtable.exists()) {
|
||||
var rows = subtable.bootstrapTable('getSelections');
|
||||
|
||||
// None selected? Use all!
|
||||
if (rows.length == 0) {
|
||||
rows = subtable.bootstrapTable('getData');
|
||||
}
|
||||
|
||||
allocateStockToBuild(
|
||||
build_info.pk,
|
||||
build_info.part,
|
||||
rows,
|
||||
{
|
||||
output: pk,
|
||||
success: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log(`WARNING: Could not locate sub-table for output ${pk}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Callack for the "unallocate" button
|
||||
$(table).find('.button-output-unallocate').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
unallocateStock(build_info.pk, {
|
||||
output: pk,
|
||||
table: table
|
||||
});
|
||||
});
|
||||
|
||||
// Callback for the "complete" button
|
||||
$(table).find('.button-output-complete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var output = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||
|
||||
completeBuildOutputs(
|
||||
build_info.pk,
|
||||
[
|
||||
output,
|
||||
],
|
||||
{
|
||||
success: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Callback for the "delete" button
|
||||
$(table).find('.button-output-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
// TODO: Move this to the API
|
||||
launchModalForm(
|
||||
`/build/${build_info.pk}/delete-output/`,
|
||||
{
|
||||
data: {
|
||||
output: pk
|
||||
},
|
||||
onSuccess: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Construct a "sub table" showing the required BOM items
|
||||
*/
|
||||
function constructBuildOutputSubTable(index, row, element) {
|
||||
var sub_table_id = `output-sub-table-${row.pk}`;
|
||||
|
||||
var html = `
|
||||
<div class='sub-table'>
|
||||
<table class='table table-striped table-condensed' id='${sub_table_id}'></table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
element.html(html);
|
||||
|
||||
loadBuildOutputAllocationTable(
|
||||
build_info,
|
||||
row,
|
||||
{
|
||||
table: `#${sub_table_id}`,
|
||||
parent_table: table,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: '{% url "api-stock-list" %}',
|
||||
queryParams: filters,
|
||||
original: params,
|
||||
showColumns: false,
|
||||
uniqueId: 'pk',
|
||||
name: 'build-outputs',
|
||||
sortable: true,
|
||||
search: false,
|
||||
sidePagination: 'server',
|
||||
detailView: has_tracked_items,
|
||||
detailFilter: function(index, row) {
|
||||
return true;
|
||||
},
|
||||
detailFormatter: function(index, row, element) {
|
||||
constructBuildOutputSubTable(index, row, element);
|
||||
},
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No active build outputs found" %}';
|
||||
},
|
||||
onPostBody: function() {
|
||||
// Add callbacks for the buttons
|
||||
setupBuildOutputButtonCallbacks();
|
||||
|
||||
$(table).bootstrapTable('expandAllRows');
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
title: '',
|
||||
visible: true,
|
||||
checkbox: true,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'part',
|
||||
title: '{% trans "Part" %}',
|
||||
formatter: function(value, row) {
|
||||
var thumb = row.part_detail.thumbnail;
|
||||
|
||||
return imageHoverIcon(thumb) + row.part_detail.full_name + makePartIcons(row.part_detail);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
formatter: function(value, row) {
|
||||
|
||||
var url = `/stock/item/${row.pk}/`;
|
||||
|
||||
var text = '';
|
||||
|
||||
if (row.serial && row.quantity == 1) {
|
||||
text = `{% trans "Serial Number" %}: ${row.serial}`;
|
||||
} else {
|
||||
text = `{% trans "Quantity" %}: ${row.quantity}`;
|
||||
}
|
||||
|
||||
return renderLink(text, url);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'allocated',
|
||||
title: '{% trans "Allocated Parts" %}',
|
||||
visible: has_tracked_items,
|
||||
formatter: function(value, row) {
|
||||
return `<div id='output-progress-${row.pk}'><span class='fas fa-spin fa-spinner'></span></div>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '',
|
||||
switchable: false,
|
||||
formatter: function(value, row) {
|
||||
return makeBuildOutputButtons(
|
||||
row.pk,
|
||||
build_info,
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Enable the "allocate" button when the sub-table is exanded
|
||||
$(table).on('expand-row.bs.table', function(detail, index, row) {
|
||||
$(`#button-output-allocate-${row.pk}`).prop('disabled', false);
|
||||
});
|
||||
|
||||
// Disable the "allocate" button when the sub-table is collapsed
|
||||
$(table).on('collapse-row.bs.table', function(detail, index, row) {
|
||||
$(`#button-output-allocate-${row.pk}`).prop('disabled', true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Display the "allocation table" for a particular build output.
|
||||
*
|
||||
* This displays a table of required allocations for a particular build output
|
||||
*
|
||||
* Args:
|
||||
* - buildId: The PK of the Build object
|
||||
@ -368,6 +681,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
* - options:
|
||||
* -- table: The #id of the table (will be auto-calculated if not provided)
|
||||
*/
|
||||
function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
|
||||
|
||||
var buildId = buildInfo.pk;
|
||||
var partId = buildInfo.part;
|
||||
@ -534,7 +849,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
},
|
||||
name: 'build-allocation',
|
||||
uniqueId: 'sub_part',
|
||||
onPostBody: setupCallbacks,
|
||||
search: options.search || false,
|
||||
onPostBody: function(data) {
|
||||
// Setup button callbacks
|
||||
setupCallbacks();
|
||||
},
|
||||
onLoadSuccess: function(tableData) {
|
||||
// Once the BOM data are loaded, request allocation data for this build output
|
||||
|
||||
@ -610,9 +929,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
$(table).bootstrapTable('updateByUniqueId', key, tableRow, true);
|
||||
}
|
||||
|
||||
// Update the total progress for this build output
|
||||
var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`));
|
||||
// Update the progress bar for this build output
|
||||
var build_progress = $(`#output-progress-${outputId}`);
|
||||
|
||||
if (build_progress.exists()) {
|
||||
if (totalLines > 0) {
|
||||
|
||||
var progress = makeProgressBar(
|
||||
@ -620,21 +940,20 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
totalLines
|
||||
);
|
||||
|
||||
buildProgress.html(progress);
|
||||
build_progress.html(progress);
|
||||
} else {
|
||||
buildProgress.html('');
|
||||
build_progress.html('');
|
||||
}
|
||||
|
||||
// Update the available actions for this build output
|
||||
|
||||
makeBuildOutputActionButtons(output, buildInfo, totalLines);
|
||||
} else {
|
||||
console.log(`WARNING: Could not find progress bar for output ${outputId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
sortable: true,
|
||||
showColumns: false,
|
||||
detailViewByClick: true,
|
||||
detailView: true,
|
||||
detailFilter: function(index, row) {
|
||||
return row.allocations != null;
|
||||
@ -883,9 +1202,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
// Initialize the action buttons
|
||||
makeBuildOutputActionButtons(output, buildInfo, 0);
|
||||
}
|
||||
|
||||
|
||||
@ -995,10 +1311,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
remaining = 0;
|
||||
}
|
||||
|
||||
// We only care about entries which are not yet fully allocated
|
||||
if (remaining > 0) {
|
||||
table_entries += renderBomItemRow(bom_item, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
if (bom_items.length == 0) {
|
||||
if (table_entries.length == 0) {
|
||||
|
||||
showAlertDialog(
|
||||
'{% trans "Select Parts" %}',
|
||||
@ -1085,6 +1404,24 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
render_part_detail: true,
|
||||
render_location_detail: true,
|
||||
auto_fill: true,
|
||||
onSelect: function(data, field, opts) {
|
||||
// Adjust the 'quantity' field based on availability
|
||||
|
||||
if (!('quantity' in data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Quantity remaining to be allocated
|
||||
var remaining = Math.max((bom_item.required || 0) - (bom_item.allocated || 0), 0);
|
||||
|
||||
// Calculate the available quantity
|
||||
var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0);
|
||||
|
||||
// Maximum amount that we need
|
||||
var desired = Math.min(available, remaining);
|
||||
|
||||
updateFieldValue(`items_quantity_${bom_item.pk}`, desired, {}, opts);
|
||||
},
|
||||
adjustFilters: function(filters) {
|
||||
// Restrict query to the selected location
|
||||
var location = getFormFieldValue(
|
||||
@ -1198,9 +1535,10 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Display a table of Build orders
|
||||
*/
|
||||
function loadBuildTable(table, options) {
|
||||
// Display a table of Build objects
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
@ -1467,190 +1805,4 @@ function loadAllocationTable(table, part_id, part, url, required, button) {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function loadBuildPartsTable(table, options={}) {
|
||||
/**
|
||||
* Display a "required parts" table for build view.
|
||||
*
|
||||
* This is a simplified BOM view:
|
||||
* - Does not display sub-bom items
|
||||
* - Does not allow editing of BOM items
|
||||
*
|
||||
* Options:
|
||||
*
|
||||
* part: Part ID
|
||||
* build: Build ID
|
||||
* build_quantity: Total build quantity
|
||||
* build_remaining: Number of items remaining
|
||||
*/
|
||||
|
||||
// Query params
|
||||
var params = {
|
||||
sub_part_detail: true,
|
||||
part: options.part,
|
||||
};
|
||||
|
||||
var filters = {};
|
||||
|
||||
if (!options.disableFilters) {
|
||||
filters = loadTableFilters('bom');
|
||||
}
|
||||
|
||||
setupFilterList('bom', $(table));
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
function setupTableCallbacks() {
|
||||
// Register button callbacks once the table data are loaded
|
||||
|
||||
// Callback for 'buy' button
|
||||
$(table).find('.button-buy').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm('{% url "order-parts" %}', {
|
||||
data: {
|
||||
parts: [
|
||||
pk,
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Callback for 'build' button
|
||||
$(table).find('.button-build').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
newBuildOrder({
|
||||
part: pk,
|
||||
parent: options.build,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var columns = [
|
||||
{
|
||||
field: 'sub_part',
|
||||
title: '{% trans "Part" %}',
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var url = `/part/${row.sub_part}/`;
|
||||
var html = imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, url);
|
||||
|
||||
var sub_part = row.sub_part_detail;
|
||||
|
||||
html += makePartIcons(row.sub_part_detail);
|
||||
|
||||
// Display an extra icon if this part is an assembly
|
||||
if (sub_part.assembly) {
|
||||
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
|
||||
|
||||
html += renderLink(text, `/part/${row.sub_part}/bom/`);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'sub_part_detail.description',
|
||||
title: '{% trans "Description" %}',
|
||||
},
|
||||
{
|
||||
field: 'reference',
|
||||
title: '{% trans "Reference" %}',
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
field: 'sub_part_detail.stock',
|
||||
title: '{% trans "Available" %}',
|
||||
formatter: function(value, row) {
|
||||
return makeProgressBar(
|
||||
value,
|
||||
row.quantity * options.build_remaining,
|
||||
{
|
||||
id: `part-progress-${row.part}`
|
||||
}
|
||||
);
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
if (rowA.received == 0 && rowB.received == 0) {
|
||||
return (rowA.quantity > rowB.quantity) ? 1 : -1;
|
||||
}
|
||||
|
||||
var progressA = parseFloat(rowA.sub_part_detail.stock) / (rowA.quantity * options.build_remaining);
|
||||
var progressB = parseFloat(rowB.sub_part_detail.stock) / (rowB.quantity * options.build_remaining);
|
||||
|
||||
return (progressA < progressB) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '{% trans "Actions" %}',
|
||||
switchable: false,
|
||||
formatter: function(value, row) {
|
||||
|
||||
// Generate action buttons against the part
|
||||
var html = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
if (row.sub_part_detail.assembly) {
|
||||
html += makeIconButton('fa-tools icon-blue', 'button-build', row.sub_part, '{% trans "Build stock" %}');
|
||||
}
|
||||
|
||||
if (row.sub_part_detail.purchaseable) {
|
||||
html += makeIconButton('fa-shopping-cart icon-blue', 'button-buy', row.sub_part, '{% trans "Order stock" %}');
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
table.inventreeTable({
|
||||
url: '{% url "api-bom-list" %}',
|
||||
showColumns: true,
|
||||
name: 'build-parts',
|
||||
sortable: true,
|
||||
search: true,
|
||||
onPostBody: setupTableCallbacks,
|
||||
rowStyle: function(row) {
|
||||
var classes = [];
|
||||
|
||||
// Shade rows differently if they are for different parent parts
|
||||
if (row.part != options.part) {
|
||||
classes.push('rowinherited');
|
||||
}
|
||||
|
||||
if (row.validated) {
|
||||
classes.push('rowvalid');
|
||||
} else {
|
||||
classes.push('rowinvalid');
|
||||
}
|
||||
|
||||
return {
|
||||
classes: classes.join(' '),
|
||||
};
|
||||
},
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No BOM items found" %}';
|
||||
},
|
||||
clickToSelect: true,
|
||||
queryParams: filters,
|
||||
original: params,
|
||||
columns: columns,
|
||||
});
|
||||
}
|
||||
|
@ -1426,6 +1426,11 @@ function initializeRelatedField(field, fields, options) {
|
||||
data = item.element.instance;
|
||||
}
|
||||
|
||||
// Run optional callback function
|
||||
if (field.onSelect && data) {
|
||||
field.onSelect(data, field, options);
|
||||
}
|
||||
|
||||
if (!data.pk) {
|
||||
return field.placeholder || '';
|
||||
}
|
||||
@ -1843,6 +1848,8 @@ function constructInput(name, parameters, options) {
|
||||
case 'candy':
|
||||
func = constructCandyInput;
|
||||
break;
|
||||
case 'raw':
|
||||
func = constructRawInput;
|
||||
default:
|
||||
// Unsupported field type!
|
||||
break;
|
||||
@ -2086,6 +2093,17 @@ function constructCandyInput(name, parameters) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct a "raw" field input
|
||||
* No actual field data!
|
||||
*/
|
||||
function constructRawInput(name, parameters) {
|
||||
|
||||
return parameters.html;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct a 'help text' div based on the field parameters
|
||||
*
|
||||
|
@ -87,8 +87,10 @@ function select2Thumbnail(image) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct an 'icon badge' which floats to the right of an object
|
||||
*/
|
||||
function makeIconBadge(icon, title) {
|
||||
// Construct an 'icon badge' which floats to the right of an object
|
||||
|
||||
var html = `<span class='fas ${icon} label-right' title='${title}'></span>`;
|
||||
|
||||
@ -96,8 +98,10 @@ function makeIconBadge(icon, title) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct an 'icon button' using the fontawesome set
|
||||
*/
|
||||
function makeIconButton(icon, cls, pk, title, options={}) {
|
||||
// Construct an 'icon button' using the fontawesome set
|
||||
|
||||
var classes = `btn btn-default btn-glyph ${cls}`;
|
||||
|
||||
|
@ -168,11 +168,7 @@ function renderPart(name, data, parameters, options) {
|
||||
|
||||
// Display available part quantity
|
||||
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
|
||||
if (data.in_stock == 0) {
|
||||
extra += `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
|
||||
} else {
|
||||
extra += `<span class='label-form label-green'>{% trans "Stock" %}: ${data.in_stock}</span>`;
|
||||
}
|
||||
extra += partStockLabel(data);
|
||||
}
|
||||
|
||||
if (!data.active) {
|
||||
|
@ -1641,6 +1641,13 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
|
||||
var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||
|
||||
// Quantity remaining to be allocated
|
||||
var remaining = (line_item.quantity || 0) - (line_item.allocated || 0);
|
||||
|
||||
if (remaining < 0) {
|
||||
remaining = 0;
|
||||
}
|
||||
|
||||
var fields = {
|
||||
// SalesOrderLineItem reference
|
||||
line: {
|
||||
@ -1654,9 +1661,26 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
in_stock: true,
|
||||
part: line_item.part,
|
||||
exclude_so_allocation: options.order,
|
||||
},
|
||||
auto_fill: true,
|
||||
onSelect: function(data, field, opts) {
|
||||
// Quantity available from this stock item
|
||||
|
||||
if (!('quantity' in data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the available quantity
|
||||
var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0);
|
||||
|
||||
// Maximum amount that we need
|
||||
var desired = Math.min(available, remaining);
|
||||
|
||||
updateFieldValue('quantity', desired, {}, opts);
|
||||
}
|
||||
},
|
||||
quantity: {
|
||||
value: remaining,
|
||||
},
|
||||
};
|
||||
|
||||
@ -1752,7 +1776,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
showFooter: true,
|
||||
uniqueId: 'pk',
|
||||
detailView: show_detail,
|
||||
detailViewByClick: show_detail,
|
||||
detailViewByClick: false,
|
||||
detailFilter: function(index, row) {
|
||||
if (pending) {
|
||||
// Order is pending
|
||||
|
@ -35,6 +35,7 @@
|
||||
loadSellPricingChart,
|
||||
loadSimplePartTable,
|
||||
loadStockPricingChart,
|
||||
partStockLabel,
|
||||
toggleStar,
|
||||
*/
|
||||
|
||||
@ -409,6 +410,18 @@ function toggleStar(options) {
|
||||
}
|
||||
|
||||
|
||||
function partStockLabel(part, options={}) {
|
||||
|
||||
var label_class = options.label_class || 'label-form';
|
||||
|
||||
if (part.in_stock) {
|
||||
return `<span class='label ${label_class} label-green'>{% trans "Stock" %}: ${part.in_stock}</span>`;
|
||||
} else {
|
||||
return `<span class='label ${label_class} label-red'>{% trans "No Stock" %}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function makePartIcons(part) {
|
||||
/* Render a set of icons for the given part.
|
||||
*/
|
||||
@ -778,7 +791,7 @@ function partGridTile(part) {
|
||||
|
||||
var html = `
|
||||
|
||||
<div class='col-sm-3 card'>
|
||||
<div class='product-card card'>
|
||||
<div class='panel panel-default panel-inventree product-card-panel'>
|
||||
<div class='panel-heading'>
|
||||
<a href='/part/${part.pk}/'>
|
||||
@ -1000,8 +1013,8 @@ function loadPartTable(table, url, options={}) {
|
||||
|
||||
data.forEach(function(row, index) {
|
||||
|
||||
// Force a new row every 4 columns, to prevent visual issues
|
||||
if ((index > 0) && (index % 4 == 0) && (index < data.length)) {
|
||||
// Force a new row every 5 columns
|
||||
if ((index > 0) && (index % 5 == 0) && (index < data.length)) {
|
||||
html += `</div><div class='row full-height'>`;
|
||||
}
|
||||
|
||||
|
@ -56,10 +56,10 @@ function enableButtons(elements, enabled) {
|
||||
}
|
||||
|
||||
|
||||
function linkButtonsToSelection(table, buttons) {
|
||||
/* Link a bootstrap-table object to one or more buttons.
|
||||
* The buttons will only be enabled if there is at least one row selected
|
||||
*/
|
||||
function linkButtonsToSelection(table, buttons) {
|
||||
|
||||
if (typeof table === 'string') {
|
||||
table = $(table);
|
||||
|
@ -1,23 +0,0 @@
|
||||
<table class='table table-striped table-condensed' id='{{ table_id }}'>
|
||||
<tr>
|
||||
<th data-field='part' data-sortable='true' data-searchable='true'>Part</th>
|
||||
<th data-field='part' data-sortable='true' data-searchable='true'>Description</th>
|
||||
<th data-field='part' data-sortable='true' data-searchable='true'>In Stock</th>
|
||||
<th data-field='part' data-sortable='true' data-searchable='true'>On Order</th>
|
||||
<th data-field='part' data-sortable='true' data-searchable='true'>Allocted</th>
|
||||
<th data-field='part' data-sortable='true' data-searchable='true'>Net Stock</th>
|
||||
</tr>
|
||||
{% for part in parts %}
|
||||
<tr>
|
||||
<td>
|
||||
{% include "hover_image.html" with image=part.image hover=True %}
|
||||
<a href="{% url 'part-detail' part.id %}">{{ part.full_name }}</a>
|
||||
</td>
|
||||
<td>{{ part.description }}</td>
|
||||
<td>{{ part.total_stock }}</td>
|
||||
<td>{{ part.on_order }}</td>
|
||||
<td>{{ part.allocation_count }}</td>
|
||||
<td{% if part.net_stock < 0 %} class='red-cell'{% endif %}>{{ part.net_stock }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
@ -1,3 +0,0 @@
|
||||
<div>
|
||||
<input fieldname='{{ field }}' class='slidey' type="checkbox" data-offstyle='warning' data-onstyle="success" data-size='small' data-toggle="toggle" {% if disabled or not roles.part.change %}disabled {% endif %}{% if state %}checked=""{% endif %} autocomplete="off">
|
||||
</div>
|
Loading…
Reference in New Issue
Block a user