Merge branch 'inventree:master' into plugin-2037

This commit is contained in:
Matthias Mair 2021-10-20 23:52:51 +02:00 committed by GitHub
commit 31a8ee7302
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1421 additions and 1451 deletions

View File

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

View File

@ -1,12 +0,0 @@
var msDelay = 0;
var delay = (function(){
return function(callback, ms){
clearTimeout(msDelay);
msDelay = setTimeout(callback, ms);
};
})();
function cancelTimer(){
clearTimeout(msDelay);
}

View File

@ -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');
}
});
}
}

View File

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

View File

@ -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'),
])),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'>
{% 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" %}
</button>
{% endif %}
<div id='build-output-toolbar'>
<div class='button-toolbar container-fluid'>
{% if build.active %}
<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>
{% 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 %}
<table class='table table-striped table-condensed' id='build-output-table' data-toolbar='#build-output-toolbar'></table>
</div>
{% endif %}
</div>
<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 }},
part: {{ build.part.pk }},
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
};
{% for item in build.incomplete_outputs %}
// Get the build output as a javascript object
inventreeGet('{% url 'api-stock-detail' item.pk %}', {},
// 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) {
loadBuildOutputAllocationTable(buildInfo, 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 %}
};
{% 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() {
// 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 }},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you wish to delete this currency?
{% endblock %}

View File

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

View File

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

View File

@ -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>
</button>
<!-- 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>
</button>
<!-- 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 %}

View File

@ -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>
</button>
<!-- 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>
</button>
<!-- 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 %}

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
{% extends "modal_form.html" %}

View File

@ -12,12 +12,14 @@
<hr>
<div class='col-sm-3' id='item-panel'>
<ul class='list-group' id='action-item-list'>
</ul>
<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>`

View File

@ -26,12 +26,14 @@
{% endif %}
<div class='col-sm-3' id='item-panel'>
<ul class='list-group' id='search-item-list'>
</ul>
<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>`

View File

@ -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" %}'>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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');
}
});
}
}

View File

@ -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.
*/
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}`);
/*
* Construct a set of output buttons for a particular build output
*/
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" this build output
html += makeIconButton(
'fa-check-circle icon-green',
'button-output-complete',
output_id,
'{% trans "Complete build output" %}',
);
// Add a button to "complete" the particular build output
html += makeIconButton(
'fa-check icon-green', 'button-output-complete', outputId,
'{% trans "Complete build output" %}',
{
// disabled: true
}
);
// Add a button to "delete" this build output
html += makeIconButton(
'fa-trash-alt icon-red',
'button-output-delete',
output_id,
'{% trans "Delete build output" %}',
);
// Add a button to "delete" the particular build output
html += makeIconButton(
'fa-trash-alt icon-red', 'button-output-delete', outputId,
'{% trans "Delete build output" %}',
);
html += `</div>`;
// TODO - Add a button to "destroy" the particular build output (mark as damaged, scrap)
}
return html;
html += '</div>';
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={}) {
}
}
});
}
/**
* 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={}) {
/**
* Load a table showing all the BuildOrder allocations for a given part
*/
options.params['part_detail'] = true;
options.params['build_detail'] = true;
@ -357,17 +433,256 @@ function loadBuildOrderAllocationTable(table, options={}) {
}
function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
/*
* 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');
}
}
);
});
}
/*
* Load the "allocation table" for a particular build output.
*
* Args:
* - buildId: The PK of the Build object
* - partId: The PK of the Part object
* - output: The StockItem object which is the "output" of the build
* - options:
* -- table: The #id of the table (will be auto-calculated if not provided)
* 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
* - partId: The PK of the Part object
* - output: The StockItem object which is the "output" of the build
* - 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,31 +929,31 @@ 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 (totalLines > 0) {
if (build_progress.exists()) {
if (totalLines > 0) {
var progress = makeProgressBar(
allocatedLines,
totalLines
);
var progress = makeProgressBar(
allocatedLines,
totalLines
);
build_progress.html(progress);
} else {
build_progress.html('');
}
buildProgress.html(progress);
} else {
buildProgress.html('');
console.log(`WARNING: Could not find progress bar for output ${outputId}`);
}
// Update the available actions for this build output
makeBuildOutputActionButtons(output, buildInfo, totalLines);
}
}
);
},
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;
}
table_entries += renderBomItemRow(bom_item, remaining);
// 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,
});
}

View File

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

View File

@ -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}`;

View File

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

View File

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

View File

@ -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'>`;
}

View File

@ -56,10 +56,10 @@ function enableButtons(elements, enabled) {
}
/* 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) {
/* Link a bootstrap-table object to one or more buttons.
* The buttons will only be enabled if there is at least one row selected
*/
if (typeof table === 'string') {
table = $(table);

View File

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

View File

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