mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' into categories_parameters
This commit is contained in:
commit
b1885138de
@ -337,7 +337,7 @@ def DownloadFile(data, filename, content_type='application/text'):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def ExtractSerialNumbers(serials, expected_quantity):
|
def extract_serial_numbers(serials, expected_quantity):
|
||||||
""" Attempt to extract serial numbers from an input string.
|
""" Attempt to extract serial numbers from an input string.
|
||||||
- Serial numbers must be integer values
|
- Serial numbers must be integer values
|
||||||
- Serial numbers must be positive
|
- Serial numbers must be positive
|
||||||
|
@ -134,20 +134,35 @@ function makeProgressBar(value, maximum, opts={}) {
|
|||||||
|
|
||||||
var extraclass = '';
|
var extraclass = '';
|
||||||
|
|
||||||
if (maximum) {
|
if (value > maximum) {
|
||||||
// TODO - Special color?
|
|
||||||
}
|
|
||||||
else if (value > maximum) {
|
|
||||||
extraclass='progress-bar-over';
|
extraclass='progress-bar-over';
|
||||||
} else if (value < maximum) {
|
} else if (value < maximum) {
|
||||||
extraclass = 'progress-bar-under';
|
extraclass = 'progress-bar-under';
|
||||||
}
|
}
|
||||||
|
|
||||||
var text = value;
|
var style = options.style || '';
|
||||||
|
|
||||||
if (maximum) {
|
var text = '';
|
||||||
text += ' / ';
|
|
||||||
text += maximum;
|
if (style == 'percent') {
|
||||||
|
// Display e.g. "50%"
|
||||||
|
|
||||||
|
text = `${percent}%`;
|
||||||
|
} else if (style == 'max') {
|
||||||
|
// Display just the maximum value
|
||||||
|
text = `${maximum}`;
|
||||||
|
} else if (style == 'value') {
|
||||||
|
// Display just the current value
|
||||||
|
text = `${value}`;
|
||||||
|
} else if (style == 'blank') {
|
||||||
|
// No display!
|
||||||
|
text = '';
|
||||||
|
} else {
|
||||||
|
/* Default style
|
||||||
|
* Display e.g. "5 / 10"
|
||||||
|
*/
|
||||||
|
|
||||||
|
text = `${value} / ${maximum}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
var id = options.id || 'progress-bar';
|
var id = options.id || 'progress-bar';
|
||||||
|
@ -134,6 +134,32 @@ function reloadFieldOptions(fieldName, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function enableField(fieldName, enabled, options={}) {
|
||||||
|
/* Enable (or disable) a particular field in a modal.
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* - fieldName: The name of the field
|
||||||
|
* - enabled: boolean enabled / disabled status
|
||||||
|
* - options:
|
||||||
|
*/
|
||||||
|
|
||||||
|
var modal = options.modal || '#modal-form';
|
||||||
|
|
||||||
|
var field = getFieldByName(modal, fieldName);
|
||||||
|
|
||||||
|
field.prop("disabled", !enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearField(fieldName, options={}) {
|
||||||
|
|
||||||
|
var modal = options.modal || '#modal-form';
|
||||||
|
|
||||||
|
var field = getFieldByName(modal, fieldName);
|
||||||
|
|
||||||
|
field.val("");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function partialMatcher(params, data) {
|
function partialMatcher(params, data) {
|
||||||
/* Replacement function for the 'matcher' parameter for a select2 dropdown.
|
/* Replacement function for the 'matcher' parameter for a select2 dropdown.
|
||||||
|
|
||||||
@ -696,6 +722,11 @@ function handleModalForm(url, options) {
|
|||||||
}
|
}
|
||||||
// Form was returned, invalid!
|
// Form was returned, invalid!
|
||||||
else {
|
else {
|
||||||
|
|
||||||
|
var warningDiv = $(modal).find('#form-validation-warning');
|
||||||
|
|
||||||
|
warningDiv.css('display', 'block');
|
||||||
|
|
||||||
if (response.html_form) {
|
if (response.html_form) {
|
||||||
injectModalForm(modal, response.html_form);
|
injectModalForm(modal, response.html_form);
|
||||||
|
|
||||||
@ -813,8 +844,13 @@ function launchModalForm(url, options = {}) {
|
|||||||
|
|
||||||
$(modal).modal('hide');
|
$(modal).modal('hide');
|
||||||
|
|
||||||
// Permission denied!
|
if (xhr.status == 0) {
|
||||||
if (xhr.status == 400) {
|
// No response from the server
|
||||||
|
showAlertDialog(
|
||||||
|
"No Response",
|
||||||
|
"No response from the InvenTree server",
|
||||||
|
);
|
||||||
|
} else if (xhr.status == 400) {
|
||||||
showAlertDialog(
|
showAlertDialog(
|
||||||
"Error 400: Bad Request",
|
"Error 400: Bad Request",
|
||||||
"Server returned error code 400"
|
"Server returned error code 400"
|
||||||
|
@ -214,25 +214,25 @@ class BuildStatus(StatusCode):
|
|||||||
|
|
||||||
# Build status codes
|
# Build status codes
|
||||||
PENDING = 10 # Build is pending / active
|
PENDING = 10 # Build is pending / active
|
||||||
ALLOCATED = 20 # Parts have been removed from stock
|
PRODUCTION = 20 # BuildOrder is in production
|
||||||
CANCELLED = 30 # Build was cancelled
|
CANCELLED = 30 # Build was cancelled
|
||||||
COMPLETE = 40 # Build is complete
|
COMPLETE = 40 # Build is complete
|
||||||
|
|
||||||
options = {
|
options = {
|
||||||
PENDING: _("Pending"),
|
PENDING: _("Pending"),
|
||||||
ALLOCATED: _("Allocated"),
|
PRODUCTION: _("Production"),
|
||||||
CANCELLED: _("Cancelled"),
|
CANCELLED: _("Cancelled"),
|
||||||
COMPLETE: _("Complete"),
|
COMPLETE: _("Complete"),
|
||||||
}
|
}
|
||||||
|
|
||||||
colors = {
|
colors = {
|
||||||
PENDING: 'blue',
|
PENDING: 'blue',
|
||||||
ALLOCATED: 'blue',
|
PRODUCTION: 'blue',
|
||||||
COMPLETE: 'green',
|
COMPLETE: 'green',
|
||||||
CANCELLED: 'red',
|
CANCELLED: 'red',
|
||||||
}
|
}
|
||||||
|
|
||||||
ACTIVE_CODES = [
|
ACTIVE_CODES = [
|
||||||
PENDING,
|
PENDING,
|
||||||
ALLOCATED
|
PRODUCTION,
|
||||||
]
|
]
|
||||||
|
@ -210,7 +210,7 @@ class TestSerialNumberExtraction(TestCase):
|
|||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
|
|
||||||
e = helpers.ExtractSerialNumbers
|
e = helpers.extract_serial_numbers
|
||||||
|
|
||||||
sn = e("1-5", 5)
|
sn = e("1-5", 5)
|
||||||
self.assertEqual(len(sn), 5)
|
self.assertEqual(len(sn), 5)
|
||||||
@ -226,7 +226,7 @@ class TestSerialNumberExtraction(TestCase):
|
|||||||
|
|
||||||
def test_failures(self):
|
def test_failures(self):
|
||||||
|
|
||||||
e = helpers.ExtractSerialNumbers
|
e = helpers.extract_serial_numbers
|
||||||
|
|
||||||
# Test duplicates
|
# Test duplicates
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
|
@ -214,26 +214,6 @@ class AjaxMixin(InvenTreeRoleMixin):
|
|||||||
"""
|
"""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def pre_save(self, obj, form, **kwargs):
|
|
||||||
"""
|
|
||||||
Hook for doing something *before* an object is saved.
|
|
||||||
|
|
||||||
obj: The object to be saved
|
|
||||||
form: The cleaned form
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Do nothing by default
|
|
||||||
pass
|
|
||||||
|
|
||||||
def post_save(self, obj, form, **kwargs):
|
|
||||||
"""
|
|
||||||
Hook for doing something *after* an object is saved.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Do nothing by default
|
|
||||||
pass
|
|
||||||
|
|
||||||
def validate(self, obj, form, **kwargs):
|
def validate(self, obj, form, **kwargs):
|
||||||
"""
|
"""
|
||||||
Hook for performing custom form validation steps.
|
Hook for performing custom form validation steps.
|
||||||
@ -363,7 +343,7 @@ class AjaxCreateView(AjaxMixin, CreateView):
|
|||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
return self.renderJsonResponse(request, form)
|
return self.renderJsonResponse(request, form)
|
||||||
|
|
||||||
def do_save(self, form):
|
def save(self, form):
|
||||||
"""
|
"""
|
||||||
Method for actually saving the form to the database.
|
Method for actually saving the form to the database.
|
||||||
Default implementation is very simple,
|
Default implementation is very simple,
|
||||||
@ -394,7 +374,9 @@ class AjaxCreateView(AjaxMixin, CreateView):
|
|||||||
|
|
||||||
# Extra JSON data sent alongside form
|
# Extra JSON data sent alongside form
|
||||||
data = {
|
data = {
|
||||||
'form_valid': valid
|
'form_valid': valid,
|
||||||
|
'form_errors': self.form.errors.as_json(),
|
||||||
|
'non_field_errors': self.form.non_field_errors().as_json(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add in any extra class data
|
# Add in any extra class data
|
||||||
@ -403,14 +385,8 @@ class AjaxCreateView(AjaxMixin, CreateView):
|
|||||||
|
|
||||||
if valid:
|
if valid:
|
||||||
|
|
||||||
# Perform (optional) pre-save step
|
|
||||||
self.pre_save(None, self.form)
|
|
||||||
|
|
||||||
# Save the object to the database
|
# Save the object to the database
|
||||||
self.do_save(self.form)
|
self.object = self.save(self.form)
|
||||||
|
|
||||||
# Perform (optional) post-save step
|
|
||||||
self.post_save(self.object, self.form)
|
|
||||||
|
|
||||||
# Return the PK of the newly-created object
|
# Return the PK of the newly-created object
|
||||||
data['pk'] = self.object.pk
|
data['pk'] = self.object.pk
|
||||||
@ -441,11 +417,14 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
|
|||||||
|
|
||||||
return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data())
|
return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data())
|
||||||
|
|
||||||
def do_save(self, form):
|
def save(self, object, form, **kwargs):
|
||||||
"""
|
"""
|
||||||
Method for updating the object in the database.
|
Method for updating the object in the database.
|
||||||
Default implementation is very simple,
|
Default implementation is very simple, but can be overridden if required.
|
||||||
but can be overridden if required.
|
|
||||||
|
Args:
|
||||||
|
object - The current object, to be updated
|
||||||
|
form - The validated form
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.object = form.save()
|
self.object = form.save()
|
||||||
@ -477,7 +456,9 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
|
|||||||
valid = form.is_valid()
|
valid = form.is_valid()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'form_valid': valid
|
'form_valid': valid,
|
||||||
|
'form_errors': form.errors.as_json(),
|
||||||
|
'non_field_errors': form.non_field_errors().as_json(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add in any extra class data
|
# Add in any extra class data
|
||||||
@ -486,22 +467,16 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
|
|||||||
|
|
||||||
if valid:
|
if valid:
|
||||||
|
|
||||||
# Perform (optional) pre-save step
|
|
||||||
self.pre_save(self.object, form)
|
|
||||||
|
|
||||||
# Save the updated objec to the database
|
# Save the updated objec to the database
|
||||||
obj = self.do_save(form)
|
self.save(self.object, form)
|
||||||
|
|
||||||
# Perform (optional) post-save step
|
self.object = self.get_object()
|
||||||
self.post_save(obj, form)
|
|
||||||
|
|
||||||
# Include context data about the updated object
|
# Include context data about the updated object
|
||||||
data['pk'] = obj.pk
|
data['pk'] = self.object.pk
|
||||||
|
|
||||||
self.post_save(obj, form)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data['url'] = obj.get_absolute_url()
|
data['url'] = self.object.get_absolute_url()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -52,14 +52,16 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
# Filter by build status?
|
# Filter by build status?
|
||||||
status = self.request.query_params.get('status', None)
|
status = params.get('status', None)
|
||||||
|
|
||||||
if status is not None:
|
if status is not None:
|
||||||
queryset = queryset.filter(status=status)
|
queryset = queryset.filter(status=status)
|
||||||
|
|
||||||
# Filter by "active" status
|
# Filter by "pending" status
|
||||||
active = self.request.query_params.get('active', None)
|
active = params.get('active', None)
|
||||||
|
|
||||||
if active is not None:
|
if active is not None:
|
||||||
active = str2bool(active)
|
active = str2bool(active)
|
||||||
@ -70,7 +72,7 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
|
queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
|
||||||
|
|
||||||
# Filter by associated part?
|
# Filter by associated part?
|
||||||
part = self.request.query_params.get('part', None)
|
part = params.get('part', None)
|
||||||
|
|
||||||
if part is not None:
|
if part is not None:
|
||||||
queryset = queryset.filter(part=part)
|
queryset = queryset.filter(part=part)
|
||||||
@ -119,14 +121,23 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
return query
|
return query
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
# Does the user wish to filter by part?
|
# Does the user wish to filter by part?
|
||||||
part_pk = self.request.query_params.get('part', None)
|
part_pk = params.get('part', None)
|
||||||
|
|
||||||
if part_pk:
|
if part_pk:
|
||||||
queryset = queryset.filter(stock_item__part=part_pk)
|
queryset = queryset.filter(stock_item__part=part_pk)
|
||||||
|
|
||||||
|
# Filter by output target
|
||||||
|
output = params.get('output', None)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
queryset = queryset.filter(install_into=output)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
@ -135,7 +146,8 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
filter_fields = [
|
filter_fields = [
|
||||||
'build',
|
'build',
|
||||||
'stock_item'
|
'stock_item',
|
||||||
|
'install_into',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -32,3 +32,51 @@
|
|||||||
lft: 0
|
lft: 0
|
||||||
rght: 0
|
rght: 0
|
||||||
tree_id: 1
|
tree_id: 1
|
||||||
|
|
||||||
|
- model: build.build
|
||||||
|
pk: 3
|
||||||
|
fields:
|
||||||
|
part: 50
|
||||||
|
reference: "0003"
|
||||||
|
title: 'Making things'
|
||||||
|
batch: 'B2'
|
||||||
|
status: 40 # COMPLETE
|
||||||
|
quantity: 21
|
||||||
|
notes: 'Some even more simple notes'
|
||||||
|
creation_date: '2019-03-16'
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
tree_id: 1
|
||||||
|
|
||||||
|
- model: build.build
|
||||||
|
pk: 4
|
||||||
|
fields:
|
||||||
|
part: 50
|
||||||
|
reference: "0004"
|
||||||
|
title: 'Making things'
|
||||||
|
batch: 'B4'
|
||||||
|
status: 40 # COMPLETE
|
||||||
|
quantity: 21
|
||||||
|
notes: 'Some even even more simple notes'
|
||||||
|
creation_date: '2019-03-16'
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
tree_id: 1
|
||||||
|
|
||||||
|
- model: build.build
|
||||||
|
pk: 5
|
||||||
|
fields:
|
||||||
|
part: 25
|
||||||
|
reference: "0005"
|
||||||
|
title: "Building some Widgets"
|
||||||
|
batch: "B10"
|
||||||
|
status: 40 # Complete
|
||||||
|
quantity: 10
|
||||||
|
creation_date: '2019-03-16'
|
||||||
|
notes: "A thing"
|
||||||
|
level: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
tree_id: 1
|
||||||
|
@ -6,11 +6,14 @@ Django Forms for interacting with Build objects
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
from django import forms
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from django import forms
|
from InvenTree.fields import RoundingDecimalFormField
|
||||||
from .models import Build, BuildItem
|
|
||||||
from stock.models import StockLocation
|
from .models import Build, BuildItem, BuildOrderAttachment
|
||||||
|
|
||||||
|
from stock.models import StockLocation, StockItem
|
||||||
|
|
||||||
|
|
||||||
class EditBuildForm(HelperForm):
|
class EditBuildForm(HelperForm):
|
||||||
@ -21,6 +24,7 @@ class EditBuildForm(HelperForm):
|
|||||||
'reference': 'BO',
|
'reference': 'BO',
|
||||||
'link': 'fa-link',
|
'link': 'fa-link',
|
||||||
'batch': 'fa-layer-group',
|
'batch': 'fa-layer-group',
|
||||||
|
'serial-numbers': 'fa-hashtag',
|
||||||
'location': 'fa-map-marker-alt',
|
'location': 'fa-map-marker-alt',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,28 +39,149 @@ class EditBuildForm(HelperForm):
|
|||||||
'title',
|
'title',
|
||||||
'part',
|
'part',
|
||||||
'quantity',
|
'quantity',
|
||||||
|
'batch',
|
||||||
|
'take_from',
|
||||||
|
'destination',
|
||||||
'parent',
|
'parent',
|
||||||
'sales_order',
|
'sales_order',
|
||||||
'take_from',
|
|
||||||
'batch',
|
|
||||||
'link',
|
'link',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class ConfirmBuildForm(HelperForm):
|
class BuildOutputCreateForm(HelperForm):
|
||||||
""" Form for auto-allocation of stock to a build """
|
"""
|
||||||
|
Form for creating a new build output.
|
||||||
|
"""
|
||||||
|
|
||||||
confirm = forms.BooleanField(required=False, help_text=_('Confirm'))
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
|
build = kwargs.pop('build', None)
|
||||||
|
|
||||||
|
if build:
|
||||||
|
self.field_placeholder['serial_numbers'] = build.part.getSerialNumberString()
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
field_prefix = {
|
||||||
|
'serial_numbers': 'fa-hashtag',
|
||||||
|
}
|
||||||
|
|
||||||
|
quantity = forms.IntegerField(
|
||||||
|
label=_('Quantity'),
|
||||||
|
help_text=_('Enter quantity for build output'),
|
||||||
|
)
|
||||||
|
|
||||||
|
serial_numbers = forms.CharField(
|
||||||
|
label=_('Serial numbers'),
|
||||||
|
required=False,
|
||||||
|
help_text=_('Enter serial numbers for build outputs'),
|
||||||
|
)
|
||||||
|
|
||||||
|
confirm = forms.BooleanField(
|
||||||
|
required=True,
|
||||||
|
label=_('Confirm'),
|
||||||
|
help_text=_('Confirm creation of build outut'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Build
|
model = Build
|
||||||
fields = [
|
fields = [
|
||||||
'confirm'
|
'quantity',
|
||||||
|
'batch',
|
||||||
|
'serial_numbers',
|
||||||
|
'confirm',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildOutputDeleteForm(HelperForm):
|
||||||
|
"""
|
||||||
|
Form for deleting a build output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
confirm = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
help_text=_('Confirm deletion of build output')
|
||||||
|
)
|
||||||
|
|
||||||
|
output_id = forms.IntegerField(
|
||||||
|
required=True,
|
||||||
|
widget=forms.HiddenInput()
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Build
|
||||||
|
fields = [
|
||||||
|
'confirm',
|
||||||
|
'output_id',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UnallocateBuildForm(HelperForm):
|
||||||
|
"""
|
||||||
|
Form for auto-de-allocation of stock from a build
|
||||||
|
"""
|
||||||
|
|
||||||
|
confirm = forms.BooleanField(required=False, help_text=_('Confirm unallocation of stock'))
|
||||||
|
|
||||||
|
output_id = forms.IntegerField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.HiddenInput()
|
||||||
|
)
|
||||||
|
|
||||||
|
part_id = forms.IntegerField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.HiddenInput(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Build
|
||||||
|
fields = [
|
||||||
|
'confirm',
|
||||||
|
'output_id',
|
||||||
|
'part_id',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AutoAllocateForm(HelperForm):
|
||||||
|
""" Form for auto-allocation of stock to a build """
|
||||||
|
|
||||||
|
confirm = forms.BooleanField(required=True, help_text=_('Confirm stock allocation'))
|
||||||
|
|
||||||
|
# Keep track of which build output we are interested in
|
||||||
|
output = forms.ModelChoiceField(
|
||||||
|
queryset=StockItem.objects.all(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Build
|
||||||
|
fields = [
|
||||||
|
'confirm',
|
||||||
|
'output',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class CompleteBuildForm(HelperForm):
|
class CompleteBuildForm(HelperForm):
|
||||||
""" Form for marking a Build as complete """
|
"""
|
||||||
|
Form for marking a build as complete
|
||||||
|
"""
|
||||||
|
|
||||||
|
confirm = forms.BooleanField(
|
||||||
|
required=True,
|
||||||
|
label=_('Confirm'),
|
||||||
|
help_text=_('Mark build as complete'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Build
|
||||||
|
fields = [
|
||||||
|
'confirm',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteBuildOutputForm(HelperForm):
|
||||||
|
"""
|
||||||
|
Form for completing a single build output
|
||||||
|
"""
|
||||||
|
|
||||||
field_prefix = {
|
field_prefix = {
|
||||||
'serial_numbers': 'fa-hashtag',
|
'serial_numbers': 'fa-hashtag',
|
||||||
@ -70,27 +195,32 @@ class CompleteBuildForm(HelperForm):
|
|||||||
help_text=_('Location of completed parts'),
|
help_text=_('Location of completed parts'),
|
||||||
)
|
)
|
||||||
|
|
||||||
serial_numbers = forms.CharField(
|
confirm_incomplete = forms.BooleanField(
|
||||||
label=_('Serial numbers'),
|
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_('Enter unique serial numbers (or leave blank)')
|
help_text=_("Confirm completion with incomplete stock allocation")
|
||||||
)
|
)
|
||||||
|
|
||||||
confirm = forms.BooleanField(required=False, help_text=_('Confirm build completion'))
|
confirm = forms.BooleanField(required=True, help_text=_('Confirm build completion'))
|
||||||
|
|
||||||
|
output = forms.ModelChoiceField(
|
||||||
|
queryset=StockItem.objects.all(), # Queryset is narrowed in the view
|
||||||
|
widget=forms.HiddenInput(),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Build
|
model = Build
|
||||||
fields = [
|
fields = [
|
||||||
'serial_numbers',
|
|
||||||
'location',
|
'location',
|
||||||
'confirm'
|
'output',
|
||||||
|
'confirm',
|
||||||
|
'confirm_incomplete',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class CancelBuildForm(HelperForm):
|
class CancelBuildForm(HelperForm):
|
||||||
""" Form for cancelling a build """
|
""" Form for cancelling a build """
|
||||||
|
|
||||||
confirm_cancel = forms.BooleanField(required=False, help_text='Confirm build cancellation')
|
confirm_cancel = forms.BooleanField(required=False, help_text=_('Confirm build cancellation'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Build
|
model = Build
|
||||||
@ -100,7 +230,13 @@ class CancelBuildForm(HelperForm):
|
|||||||
|
|
||||||
|
|
||||||
class EditBuildItemForm(HelperForm):
|
class EditBuildItemForm(HelperForm):
|
||||||
""" Form for adding a new BuildItem to a Build """
|
"""
|
||||||
|
Form for creating (or editing) a BuildItem object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, help_text=_('Select quantity of stock to allocate'))
|
||||||
|
|
||||||
|
part_id = forms.IntegerField(required=False, widget=forms.HiddenInput())
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = BuildItem
|
model = BuildItem
|
||||||
@ -108,4 +244,19 @@ class EditBuildItemForm(HelperForm):
|
|||||||
'build',
|
'build',
|
||||||
'stock_item',
|
'stock_item',
|
||||||
'quantity',
|
'quantity',
|
||||||
|
'install_into',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EditBuildAttachmentForm(HelperForm):
|
||||||
|
"""
|
||||||
|
Form for creating / editing a BuildAttachment object
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = BuildOrderAttachment
|
||||||
|
fields = [
|
||||||
|
'build',
|
||||||
|
'attachment',
|
||||||
|
'comment'
|
||||||
]
|
]
|
||||||
|
@ -0,0 +1,64 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2020-10-25 21:33
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import mptt.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
replaces = [('build', '0021_auto_20201020_0908'), ('build', '0022_auto_20201020_0953'), ('build', '0023_auto_20201020_1009'), ('build', '0024_auto_20201020_1144'), ('build', '0025_auto_20201020_1248'), ('build', '0026_auto_20201023_1228')]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0052_stockitem_is_building'),
|
||||||
|
('build', '0020_auto_20201019_1325'),
|
||||||
|
('part', '0051_bomitem_optional'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='builditem',
|
||||||
|
name='install_into',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Destination stock item', limit_choices_to={'is_building': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items_to_install', to='stock.StockItem'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='builditem',
|
||||||
|
name='stock_item',
|
||||||
|
field=models.ForeignKey(help_text='Source stock item', limit_choices_to={'belongs_to': None, 'build_order': None, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='build',
|
||||||
|
name='destination',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Select location where the completed items will be stored', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_builds', to='stock.StockLocation', verbose_name='Destination Location'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='build',
|
||||||
|
name='parent',
|
||||||
|
field=mptt.fields.TreeForeignKey(blank=True, help_text='BuildOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build', verbose_name='Parent Build'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='build',
|
||||||
|
name='status',
|
||||||
|
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Production'), (30, 'Cancelled'), (40, 'Complete')], default=10, help_text='Build status code', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Build Status'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='build',
|
||||||
|
name='part',
|
||||||
|
field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'assembly': True, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part', verbose_name='Part'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='build',
|
||||||
|
name='completed',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='Number of stock items which have been completed', verbose_name='Completed items'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='build',
|
||||||
|
name='quantity',
|
||||||
|
field=models.PositiveIntegerField(default=1, help_text='Number of stock items to build', validators=[django.core.validators.MinValueValidator(1)], verbose_name='Build Quantity'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='builditem',
|
||||||
|
unique_together={('build', 'stock_item', 'install_into')},
|
||||||
|
),
|
||||||
|
]
|
31
InvenTree/build/migrations/0022_buildorderattachment.py
Normal file
31
InvenTree/build/migrations/0022_buildorderattachment.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2020-10-26 04:17
|
||||||
|
|
||||||
|
import InvenTree.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('build', '0021_auto_20201020_0908_squashed_0026_auto_20201023_1228'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BuildOrderAttachment',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
|
||||||
|
('comment', models.CharField(blank=True, help_text='File comment', max_length=100)),
|
||||||
|
('upload_date', models.DateField(auto_now_add=True, null=True)),
|
||||||
|
('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='build.Build')),
|
||||||
|
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -5,6 +5,7 @@ Build database model definitions
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@ -22,8 +23,9 @@ from markdownx.models import MarkdownxField
|
|||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
from InvenTree.helpers import increment, getSetting
|
from InvenTree.helpers import increment, getSetting, normalize
|
||||||
from InvenTree.validators import validate_build_order_reference
|
from InvenTree.validators import validate_build_order_reference
|
||||||
|
from InvenTree.models import InvenTreeAttachment
|
||||||
|
|
||||||
import InvenTree.fields
|
import InvenTree.fields
|
||||||
|
|
||||||
@ -32,7 +34,7 @@ from part import models as PartModels
|
|||||||
|
|
||||||
|
|
||||||
class Build(MPTTModel):
|
class Build(MPTTModel):
|
||||||
""" A Build object organises the creation of new parts from the component parts.
|
""" A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
part: The part to be built (from component BOM items)
|
part: The part to be built (from component BOM items)
|
||||||
@ -63,22 +65,6 @@ class Build(MPTTModel):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('build-detail', kwargs={'pk': self.id})
|
return reverse('build-detail', kwargs={'pk': self.id})
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
"""
|
|
||||||
Validation for Build object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
super().clean()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if self.part.trackable:
|
|
||||||
if not self.quantity == int(self.quantity):
|
|
||||||
raise ValidationError({
|
|
||||||
'quantity': _("Build quantity must be integer value for trackable parts")
|
|
||||||
})
|
|
||||||
except PartModels.Part.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
reference = models.CharField(
|
reference = models.CharField(
|
||||||
unique=True,
|
unique=True,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
@ -103,7 +89,7 @@ class Build(MPTTModel):
|
|||||||
blank=True, null=True,
|
blank=True, null=True,
|
||||||
related_name='children',
|
related_name='children',
|
||||||
verbose_name=_('Parent Build'),
|
verbose_name=_('Parent Build'),
|
||||||
help_text=_('Parent build to which this build is allocated'),
|
help_text=_('BuildOrder to which this build is allocated'),
|
||||||
)
|
)
|
||||||
|
|
||||||
part = models.ForeignKey(
|
part = models.ForeignKey(
|
||||||
@ -112,7 +98,6 @@ class Build(MPTTModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='builds',
|
related_name='builds',
|
||||||
limit_choices_to={
|
limit_choices_to={
|
||||||
'is_template': False,
|
|
||||||
'assembly': True,
|
'assembly': True,
|
||||||
'active': True,
|
'active': True,
|
||||||
'virtual': False,
|
'virtual': False,
|
||||||
@ -138,11 +123,26 @@ class Build(MPTTModel):
|
|||||||
help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)')
|
help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
destination = models.ForeignKey(
|
||||||
|
'stock.StockLocation',
|
||||||
|
verbose_name=_('Destination Location'),
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='incoming_builds',
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text=_('Select location where the completed items will be stored'),
|
||||||
|
)
|
||||||
|
|
||||||
quantity = models.PositiveIntegerField(
|
quantity = models.PositiveIntegerField(
|
||||||
verbose_name=_('Build Quantity'),
|
verbose_name=_('Build Quantity'),
|
||||||
default=1,
|
default=1,
|
||||||
validators=[MinValueValidator(1)],
|
validators=[MinValueValidator(1)],
|
||||||
help_text=_('Number of parts to build')
|
help_text=_('Number of stock items to build')
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = models.PositiveIntegerField(
|
||||||
|
verbose_name=_('Completed items'),
|
||||||
|
default=0,
|
||||||
|
help_text=_('Number of stock items which have been completed')
|
||||||
)
|
)
|
||||||
|
|
||||||
status = models.PositiveIntegerField(
|
status = models.PositiveIntegerField(
|
||||||
@ -182,10 +182,96 @@ class Build(MPTTModel):
|
|||||||
blank=True, help_text=_('Extra build notes')
|
blank=True, help_text=_('Extra build notes')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bom_items(self):
|
||||||
|
"""
|
||||||
|
Returns the BOM items for the part referenced by this BuildOrder
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.part.bom_items.all().prefetch_related(
|
||||||
|
'sub_part'
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining(self):
|
||||||
|
"""
|
||||||
|
Return the number of outputs remaining to be completed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return max(0, self.quantity - self.completed)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def output_count(self):
|
def output_count(self):
|
||||||
return self.build_outputs.count()
|
return self.build_outputs.count()
|
||||||
|
|
||||||
|
def get_build_outputs(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Return a list of build outputs.
|
||||||
|
|
||||||
|
kwargs:
|
||||||
|
complete = (True / False) - If supplied, filter by completed status
|
||||||
|
in_stock = (True / False) - If supplied, filter by 'in-stock' status
|
||||||
|
"""
|
||||||
|
|
||||||
|
outputs = self.build_outputs.all()
|
||||||
|
|
||||||
|
# Filter by 'in stock' status
|
||||||
|
in_stock = kwargs.get('in_stock', None)
|
||||||
|
|
||||||
|
if in_stock is not None:
|
||||||
|
if in_stock:
|
||||||
|
outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||||
|
else:
|
||||||
|
outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER)
|
||||||
|
|
||||||
|
# Filter by 'complete' status
|
||||||
|
complete = kwargs.get('complete', None)
|
||||||
|
|
||||||
|
if complete is not None:
|
||||||
|
if complete:
|
||||||
|
outputs = outputs.filter(is_building=False)
|
||||||
|
else:
|
||||||
|
outputs = outputs.filter(is_building=True)
|
||||||
|
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def complete_outputs(self):
|
||||||
|
"""
|
||||||
|
Return all the "completed" build outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
outputs = self.get_build_outputs(complete=True)
|
||||||
|
|
||||||
|
# TODO - Ordering?
|
||||||
|
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def incomplete_outputs(self):
|
||||||
|
"""
|
||||||
|
Return all the "incomplete" build outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
outputs = self.get_build_outputs(complete=False)
|
||||||
|
|
||||||
|
# TODO - Order by how "complete" they are?
|
||||||
|
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def incomplete_count(self):
|
||||||
|
"""
|
||||||
|
Return the total number of "incomplete" outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
quantity = 0
|
||||||
|
|
||||||
|
for output in self.incomplete_outputs:
|
||||||
|
quantity += output.quantity
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def getNextBuildNumber(cls):
|
def getNextBuildNumber(cls):
|
||||||
"""
|
"""
|
||||||
@ -218,6 +304,42 @@ class Build(MPTTModel):
|
|||||||
|
|
||||||
return new_ref
|
return new_ref
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_complete(self):
|
||||||
|
"""
|
||||||
|
Returns True if this build can be "completed"
|
||||||
|
|
||||||
|
- Must not have any outstanding build outputs
|
||||||
|
- 'completed' value must meet (or exceed) the 'quantity' value
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.incomplete_count > 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.completed < self.quantity:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# No issues!
|
||||||
|
return True
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def complete_build(self, user):
|
||||||
|
"""
|
||||||
|
Mark this build as complete
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.can_complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.completion_date = datetime.now().date()
|
||||||
|
self.completed_by = user
|
||||||
|
self.status = BuildStatus.COMPLETE
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# Ensure that there are no longer any BuildItem objects
|
||||||
|
# which point to thie Build Order
|
||||||
|
self.allocated_stock.all().delete()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def cancelBuild(self, user):
|
def cancelBuild(self, user):
|
||||||
""" Mark the Build as CANCELLED
|
""" Mark the Build as CANCELLED
|
||||||
@ -237,58 +359,77 @@ class Build(MPTTModel):
|
|||||||
self.status = BuildStatus.CANCELLED
|
self.status = BuildStatus.CANCELLED
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def getAutoAllocations(self):
|
def getAutoAllocations(self, output):
|
||||||
""" Return a list of parts which will be allocated
|
"""
|
||||||
|
Return a list of StockItem objects which will be allocated
|
||||||
using the 'AutoAllocate' function.
|
using the 'AutoAllocate' function.
|
||||||
|
|
||||||
For each item in the BOM for the attached Part:
|
For each item in the BOM for the attached Part,
|
||||||
|
the following tests must *all* evaluate to True,
|
||||||
|
for the part to be auto-allocated:
|
||||||
|
|
||||||
- If there is a single StockItem, use that StockItem
|
- The sub_item in the BOM line must *not* be trackable
|
||||||
- Take as many parts as available (up to the quantity required for the BOM)
|
- There is only a single stock item available (which has not already been allocated to this build)
|
||||||
- If there are multiple StockItems available, ignore (leave up to the user)
|
- The stock item has an availability greater than zero
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list object containing the StockItem objects to be allocated (and the quantities)
|
A list object containing the StockItem objects to be allocated (and the quantities).
|
||||||
|
Each item in the list is a dict as follows:
|
||||||
|
{
|
||||||
|
'stock_item': stock_item,
|
||||||
|
'quantity': stock_quantity,
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
allocations = []
|
allocations = []
|
||||||
|
|
||||||
for item in self.part.bom_items.all().prefetch_related('sub_part'):
|
"""
|
||||||
|
Iterate through each item in the BOM
|
||||||
|
"""
|
||||||
|
|
||||||
# How many parts required for this build?
|
for bom_item in self.bom_items:
|
||||||
q_required = item.quantity * self.quantity
|
|
||||||
|
|
||||||
# Grab a list of StockItem objects which are "in stock"
|
part = bom_item.sub_part
|
||||||
stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
|
||||||
|
|
||||||
# Filter by part reference
|
# Skip any parts which are already fully allocated
|
||||||
stock = stock.filter(part=item.sub_part)
|
if self.isPartFullyAllocated(part, output):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# How many parts are required to complete the output?
|
||||||
|
required = self.unallocatedQuantity(part, output)
|
||||||
|
|
||||||
|
# Grab a list of stock items which are available
|
||||||
|
stock_items = self.availableStockItems(part, output)
|
||||||
|
|
||||||
# Ensure that the available stock items are in the correct location
|
# Ensure that the available stock items are in the correct location
|
||||||
if self.take_from is not None:
|
if self.take_from is not None:
|
||||||
# Filter for stock that is located downstream of the designated location
|
# Filter for stock that is located downstream of the designated location
|
||||||
stock = stock.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()])
|
stock_items = stock_items.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()])
|
||||||
|
|
||||||
# Only one StockItem to choose from? Default to that one!
|
# Only one StockItem to choose from? Default to that one!
|
||||||
if len(stock) == 1:
|
if stock_items.count() == 1:
|
||||||
stock_item = stock[0]
|
stock_item = stock_items[0]
|
||||||
|
|
||||||
# Check that we have not already allocated this stock-item against this build
|
# Double check that we have not already allocated this stock-item against this build
|
||||||
build_items = BuildItem.objects.filter(build=self, stock_item=stock_item)
|
build_items = BuildItem.objects.filter(
|
||||||
|
build=self,
|
||||||
|
stock_item=stock_item,
|
||||||
|
install_into=output
|
||||||
|
)
|
||||||
|
|
||||||
if len(build_items) > 0:
|
if len(build_items) > 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Are there any parts available?
|
# How many items are actually available?
|
||||||
if stock_item.quantity > 0:
|
if stock_item.quantity > 0:
|
||||||
|
|
||||||
# Only take as many as are available
|
# Only take as many as are available
|
||||||
if stock_item.quantity < q_required:
|
if stock_item.quantity < required:
|
||||||
q_required = stock_item.quantity
|
required = stock_item.quantity
|
||||||
|
|
||||||
allocation = {
|
allocation = {
|
||||||
'stock_item': stock_item,
|
'stock_item': stock_item,
|
||||||
'quantity': q_required,
|
'quantity': required,
|
||||||
}
|
}
|
||||||
|
|
||||||
allocations.append(allocation)
|
allocations.append(allocation)
|
||||||
@ -296,14 +437,130 @@ class Build(MPTTModel):
|
|||||||
return allocations
|
return allocations
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def unallocateStock(self):
|
def unallocateStock(self, output=None, part=None):
|
||||||
""" Deletes all stock allocations for this build. """
|
"""
|
||||||
|
Deletes all stock allocations for this build.
|
||||||
|
|
||||||
BuildItem.objects.filter(build=self.id).delete()
|
Args:
|
||||||
|
output: Specify which build output to delete allocations (optional)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
allocations = BuildItem.objects.filter(build=self.pk)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
allocations = allocations.filter(install_into=output.pk)
|
||||||
|
|
||||||
|
if part:
|
||||||
|
allocations = allocations.filter(stock_item__part=part)
|
||||||
|
|
||||||
|
# Remove all the allocations
|
||||||
|
allocations.delete()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def autoAllocate(self):
|
def create_build_output(self, quantity, **kwargs):
|
||||||
""" Run auto-allocation routine to allocate StockItems to this Build.
|
"""
|
||||||
|
Create a new build output against this BuildOrder.
|
||||||
|
|
||||||
|
args:
|
||||||
|
quantity: The quantity of the item to produce
|
||||||
|
|
||||||
|
kwargs:
|
||||||
|
batch: Override batch code
|
||||||
|
serials: Serial numbers
|
||||||
|
location: Override location
|
||||||
|
"""
|
||||||
|
|
||||||
|
batch = kwargs.get('batch', self.batch)
|
||||||
|
location = kwargs.get('location', self.destination)
|
||||||
|
serials = kwargs.get('serials', None)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Determine if we can create a single output (with quantity > 0),
|
||||||
|
or multiple outputs (with quantity = 1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
multiple = False
|
||||||
|
|
||||||
|
# Serial numbers are provided? We need to split!
|
||||||
|
if serials:
|
||||||
|
multiple = True
|
||||||
|
|
||||||
|
# BOM has trackable parts, so we must split!
|
||||||
|
if self.part.has_trackable_parts:
|
||||||
|
multiple = True
|
||||||
|
|
||||||
|
if multiple:
|
||||||
|
"""
|
||||||
|
Create multiple build outputs with a single quantity of 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
for ii in range(quantity):
|
||||||
|
|
||||||
|
if serials:
|
||||||
|
serial = serials[ii]
|
||||||
|
else:
|
||||||
|
serial = None
|
||||||
|
|
||||||
|
StockModels.StockItem.objects.create(
|
||||||
|
quantity=1,
|
||||||
|
location=location,
|
||||||
|
part=self.part,
|
||||||
|
build=self,
|
||||||
|
batch=batch,
|
||||||
|
serial=serial,
|
||||||
|
is_building=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
"""
|
||||||
|
Create a single build output of the given quantity
|
||||||
|
"""
|
||||||
|
|
||||||
|
StockModels.StockItem.objects.create(
|
||||||
|
quantity=quantity,
|
||||||
|
location=location,
|
||||||
|
part=self.part,
|
||||||
|
build=self,
|
||||||
|
batch=batch,
|
||||||
|
is_building=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.status == BuildStatus.PENDING:
|
||||||
|
self.status = BuildStatus.PRODUCTION
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def deleteBuildOutput(self, output):
|
||||||
|
"""
|
||||||
|
Remove a build output from the database:
|
||||||
|
|
||||||
|
- Unallocate any build items against the output
|
||||||
|
- Delete the output StockItem
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
raise ValidationError(_("No build output specified"))
|
||||||
|
|
||||||
|
if not output.is_building:
|
||||||
|
raise ValidationError(_("Build output is already completed"))
|
||||||
|
|
||||||
|
if not output.build == self:
|
||||||
|
raise ValidationError(_("Build output does not match Build Order"))
|
||||||
|
|
||||||
|
# Unallocate all build items against the output
|
||||||
|
self.unallocateStock(output)
|
||||||
|
|
||||||
|
# Remove the build output from the database
|
||||||
|
output.delete()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def autoAllocate(self, output):
|
||||||
|
"""
|
||||||
|
Run auto-allocation routine to allocate StockItems to this Build.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output: If specified, only auto-allocate against the given built output
|
||||||
|
|
||||||
Returns a list of dict objects with keys like:
|
Returns a list of dict objects with keys like:
|
||||||
|
|
||||||
@ -315,128 +572,171 @@ class Build(MPTTModel):
|
|||||||
See: getAutoAllocations()
|
See: getAutoAllocations()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
allocations = self.getAutoAllocations()
|
allocations = self.getAutoAllocations(output)
|
||||||
|
|
||||||
for item in allocations:
|
for item in allocations:
|
||||||
# Create a new allocation
|
# Create a new allocation
|
||||||
build_item = BuildItem(
|
build_item = BuildItem(
|
||||||
build=self,
|
build=self,
|
||||||
stock_item=item['stock_item'],
|
stock_item=item['stock_item'],
|
||||||
quantity=item['quantity'])
|
quantity=item['quantity'],
|
||||||
|
install_into=output,
|
||||||
|
)
|
||||||
|
|
||||||
build_item.save()
|
build_item.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def completeBuild(self, location, serial_numbers, user):
|
def completeBuildOutput(self, output, user, **kwargs):
|
||||||
""" Mark the Build as COMPLETE
|
"""
|
||||||
|
Complete a particular build output
|
||||||
|
|
||||||
- Takes allocated items from stock
|
- Remove allocated StockItems
|
||||||
- Delete pending BuildItem objects
|
- Mark the output as complete
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Complete the build allocation for each BuildItem
|
# List the allocated BuildItem objects for the given output
|
||||||
for build_item in self.allocated_stock.all().prefetch_related('stock_item'):
|
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 celery / redis to offload the actual object deletion...
|
||||||
|
# REF: https://www.botreetechnologies.com/blog/implementing-celery-using-django-for-background-task-processing
|
||||||
|
# REF: https://code.tutsplus.com/tutorials/using-celery-with-django-for-background-task-processing--cms-28732
|
||||||
|
|
||||||
|
# Complete the allocation of stock for that item
|
||||||
build_item.complete_allocation(user)
|
build_item.complete_allocation(user)
|
||||||
|
|
||||||
# Check that the stock-item has been assigned to this build, and remove the builditem from the database
|
# Delete the BuildItem objects from the database
|
||||||
if build_item.stock_item.build_order == self:
|
allocated_items.all().delete()
|
||||||
build_item.delete()
|
|
||||||
|
|
||||||
notes = 'Built {q} on {now}'.format(
|
# Ensure that the output is updated correctly
|
||||||
q=self.quantity,
|
output.build = self
|
||||||
now=str(datetime.now().date())
|
output.is_building = False
|
||||||
|
|
||||||
|
output.save()
|
||||||
|
|
||||||
|
output.addTransactionNote(
|
||||||
|
_('Completed build output'),
|
||||||
|
user,
|
||||||
|
system=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate the build outputs
|
# Increase the completed quantity for this build
|
||||||
if self.part.trackable and serial_numbers:
|
self.completed += output.quantity
|
||||||
# Add new serial numbers
|
|
||||||
for serial in serial_numbers:
|
|
||||||
item = StockModels.StockItem.objects.create(
|
|
||||||
part=self.part,
|
|
||||||
build=self,
|
|
||||||
location=location,
|
|
||||||
quantity=1,
|
|
||||||
serial=serial,
|
|
||||||
batch=str(self.batch) if self.batch else '',
|
|
||||||
notes=notes
|
|
||||||
)
|
|
||||||
|
|
||||||
item.save()
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Add stock of the newly created item
|
|
||||||
item = StockModels.StockItem.objects.create(
|
|
||||||
part=self.part,
|
|
||||||
build=self,
|
|
||||||
location=location,
|
|
||||||
quantity=self.quantity,
|
|
||||||
batch=str(self.batch) if self.batch else '',
|
|
||||||
notes=notes
|
|
||||||
)
|
|
||||||
|
|
||||||
item.save()
|
|
||||||
|
|
||||||
# Finally, mark the build as complete
|
|
||||||
self.completion_date = datetime.now().date()
|
|
||||||
self.completed_by = user
|
|
||||||
self.status = BuildStatus.COMPLETE
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
return True
|
def requiredQuantity(self, part, output):
|
||||||
|
|
||||||
def isFullyAllocated(self):
|
|
||||||
"""
|
"""
|
||||||
Return True if this build has been fully allocated.
|
Get the quantity of a part required to complete the particular build output.
|
||||||
"""
|
|
||||||
|
Args:
|
||||||
bom_items = self.part.bom_items.all()
|
part: The Part object
|
||||||
|
output - The particular build output (StockItem)
|
||||||
for item in bom_items:
|
|
||||||
part = item.sub_part
|
|
||||||
|
|
||||||
if not self.isPartFullyAllocated(part):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def isPartFullyAllocated(self, part):
|
|
||||||
"""
|
|
||||||
Check if a given Part is fully allocated for this Build
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.getAllocatedQuantity(part) >= self.getRequiredQuantity(part)
|
|
||||||
|
|
||||||
def getRequiredQuantity(self, part):
|
|
||||||
""" Calculate the quantity of <part> required to make this build.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Extract the BOM line item from the database
|
||||||
try:
|
try:
|
||||||
item = PartModels.BomItem.objects.get(part=self.part.id, sub_part=part.id)
|
bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk)
|
||||||
q = item.quantity
|
quantity = bom_item.quantity
|
||||||
except PartModels.BomItem.DoesNotExist:
|
except (PartModels.BomItem.DoesNotExist):
|
||||||
q = 0
|
quantity = 0
|
||||||
|
|
||||||
return q * self.quantity
|
if output:
|
||||||
|
quantity *= output.quantity
|
||||||
|
else:
|
||||||
|
quantity *= self.remaining
|
||||||
|
|
||||||
def getAllocatedQuantity(self, part):
|
return quantity
|
||||||
""" Calculate the total number of <part> currently allocated to this build
|
|
||||||
|
def allocatedItems(self, part, output):
|
||||||
|
"""
|
||||||
|
Return all BuildItem objects which allocate stock of <part> to <output>
|
||||||
|
|
||||||
|
Args:
|
||||||
|
part - The part object
|
||||||
|
output - Build output (StockItem).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(q=Coalesce(Sum('quantity'), 0))
|
allocations = BuildItem.objects.filter(
|
||||||
|
build=self,
|
||||||
|
stock_item__part=part,
|
||||||
|
install_into=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return allocations
|
||||||
|
|
||||||
|
def allocatedQuantity(self, part, output):
|
||||||
|
"""
|
||||||
|
Return the total quantity of given part allocated to a given build output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
allocations = self.allocatedItems(part, output)
|
||||||
|
|
||||||
|
allocated = allocations.aggregate(q=Coalesce(Sum('quantity'), 0))
|
||||||
|
|
||||||
return allocated['q']
|
return allocated['q']
|
||||||
|
|
||||||
def getUnallocatedQuantity(self, part):
|
def unallocatedQuantity(self, part, output):
|
||||||
""" Calculate the quantity of <part> which still needs to be allocated to this build.
|
"""
|
||||||
|
Return the total unallocated (remaining) quantity of a part against a particular output.
|
||||||
Args:
|
|
||||||
Part - the part to be tested
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The remaining allocated quantity
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return max(self.getRequiredQuantity(part) - self.getAllocatedQuantity(part), 0)
|
required = self.requiredQuantity(part, output)
|
||||||
|
allocated = self.allocatedQuantity(part, output)
|
||||||
|
|
||||||
|
return max(required - allocated, 0)
|
||||||
|
|
||||||
|
def isPartFullyAllocated(self, part, output):
|
||||||
|
"""
|
||||||
|
Returns True if the part has been fully allocated to the particular build output
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.unallocatedQuantity(part, output) == 0
|
||||||
|
|
||||||
|
def isFullyAllocated(self, output):
|
||||||
|
"""
|
||||||
|
Returns True if the particular build output is fully allocated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for bom_item in self.bom_items:
|
||||||
|
part = bom_item.sub_part
|
||||||
|
|
||||||
|
if not self.isPartFullyAllocated(part, output):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# All parts must be fully allocated!
|
||||||
|
return True
|
||||||
|
|
||||||
|
def allocatedParts(self, output):
|
||||||
|
"""
|
||||||
|
Return a list of parts which have been fully allocated against a particular output
|
||||||
|
"""
|
||||||
|
|
||||||
|
allocated = []
|
||||||
|
|
||||||
|
for bom_item in self.bom_items:
|
||||||
|
part = bom_item.sub_part
|
||||||
|
|
||||||
|
if self.isPartFullyAllocated(part, output):
|
||||||
|
allocated.append(part)
|
||||||
|
|
||||||
|
return allocated
|
||||||
|
|
||||||
|
def unallocatedParts(self, output):
|
||||||
|
"""
|
||||||
|
Return a list of parts which have *not* been fully allocated against a particular output
|
||||||
|
"""
|
||||||
|
|
||||||
|
unallocated = []
|
||||||
|
|
||||||
|
for bom_item in self.bom_items:
|
||||||
|
part = bom_item.sub_part
|
||||||
|
|
||||||
|
if not self.isPartFullyAllocated(part, output):
|
||||||
|
unallocated.append(part)
|
||||||
|
|
||||||
|
return unallocated
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def required_parts(self):
|
def required_parts(self):
|
||||||
@ -444,26 +744,44 @@ class Build(MPTTModel):
|
|||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
for item in self.part.bom_items.all().prefetch_related('sub_part'):
|
for item in self.part.bom_items.all().prefetch_related('sub_part'):
|
||||||
part = {
|
parts.append(item.sub_part)
|
||||||
'part': item.sub_part,
|
|
||||||
'per_build': item.quantity,
|
|
||||||
'quantity': item.quantity * self.quantity,
|
|
||||||
'allocated': self.getAllocatedQuantity(item.sub_part)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.append(part)
|
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
@property
|
def availableStockItems(self, part, output):
|
||||||
def can_build(self):
|
"""
|
||||||
""" Return true if there are enough parts to supply build """
|
Returns stock items which are available for allocation to this build.
|
||||||
|
|
||||||
for item in self.required_parts:
|
Args:
|
||||||
if item['part'].total_stock < item['quantity']:
|
part - Part object
|
||||||
return False
|
output - The particular build output
|
||||||
|
"""
|
||||||
|
|
||||||
return True
|
# Grab initial query for items which are "in stock" and match the part
|
||||||
|
items = StockModels.StockItem.objects.filter(
|
||||||
|
StockModels.StockItem.IN_STOCK_FILTER
|
||||||
|
)
|
||||||
|
|
||||||
|
items = items.filter(part=part)
|
||||||
|
|
||||||
|
# Exclude any items which have already been allocated
|
||||||
|
allocated = BuildItem.objects.filter(
|
||||||
|
build=self,
|
||||||
|
stock_item__part=part,
|
||||||
|
install_into=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = items.exclude(
|
||||||
|
id__in=[item.stock_item.id for item in allocated.all()]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Limit query to stock items which are "downstream" of the source location
|
||||||
|
if self.take_from is not None:
|
||||||
|
items = items.filter(
|
||||||
|
location__in=[loc for loc in self.take_from.getUniqueChildren()]
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
@ -478,9 +796,21 @@ class Build(MPTTModel):
|
|||||||
@property
|
@property
|
||||||
def is_complete(self):
|
def is_complete(self):
|
||||||
""" Returns True if the build status is COMPLETE """
|
""" Returns True if the build status is COMPLETE """
|
||||||
|
|
||||||
return self.status == BuildStatus.COMPLETE
|
return self.status == BuildStatus.COMPLETE
|
||||||
|
|
||||||
|
|
||||||
|
class BuildOrderAttachment(InvenTreeAttachment):
|
||||||
|
"""
|
||||||
|
Model for storing file attachments against a BuildOrder object
|
||||||
|
"""
|
||||||
|
|
||||||
|
def getSubdir(self):
|
||||||
|
return os.path.join('bo_files', str(self.build.id))
|
||||||
|
|
||||||
|
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
|
||||||
|
|
||||||
|
|
||||||
class BuildItem(models.Model):
|
class BuildItem(models.Model):
|
||||||
""" A BuildItem links multiple StockItem objects to a Build.
|
""" A BuildItem links multiple StockItem objects to a Build.
|
||||||
These are used to allocate part stock to a build.
|
These are used to allocate part stock to a build.
|
||||||
@ -500,9 +830,38 @@ class BuildItem(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [
|
unique_together = [
|
||||||
('build', 'stock_item'),
|
('build', 'stock_item', 'install_into'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
self.validate_unique()
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
super().save()
|
||||||
|
|
||||||
|
def validate_unique(self, exclude=None):
|
||||||
|
"""
|
||||||
|
Test that this BuildItem object is "unique".
|
||||||
|
Essentially we do not want a stock_item being allocated to a Build multiple times.
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().validate_unique(exclude)
|
||||||
|
|
||||||
|
items = BuildItem.objects.exclude(id=self.id).filter(
|
||||||
|
build=self.build,
|
||||||
|
stock_item=self.stock_item,
|
||||||
|
install_into=self.install_into
|
||||||
|
)
|
||||||
|
|
||||||
|
if items.exists():
|
||||||
|
msg = _("BuildItem must be unique for build, stock_item and install_into")
|
||||||
|
raise ValidationError({
|
||||||
|
'build': msg,
|
||||||
|
'stock_item': msg,
|
||||||
|
'install_into': msg
|
||||||
|
})
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
""" Check validity of the BuildItem model.
|
""" Check validity of the BuildItem model.
|
||||||
The following checks are performed:
|
The following checks are performed:
|
||||||
@ -511,27 +870,37 @@ class BuildItem(models.Model):
|
|||||||
- Allocation quantity cannot exceed available quantity
|
- Allocation quantity cannot exceed available quantity
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super(BuildItem, self).clean()
|
self.validate_unique()
|
||||||
|
|
||||||
|
super().clean()
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
|
if not self.install_into:
|
||||||
|
raise ValidationError(_('Build item must specify a build output'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.stock_item.part not in self.build.part.required_parts():
|
# Allocated part must be in the BOM for the master part
|
||||||
|
if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False):
|
||||||
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'".format(p=self.build.part.full_name))]
|
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'".format(p=self.build.part.full_name))]
|
||||||
|
|
||||||
|
# Allocated quantity cannot exceed available stock quantity
|
||||||
if self.quantity > self.stock_item.quantity:
|
if self.quantity > self.stock_item.quantity:
|
||||||
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})".format(
|
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})".format(
|
||||||
n=self.quantity,
|
n=normalize(self.quantity),
|
||||||
q=self.stock_item.quantity
|
q=normalize(self.stock_item.quantity)
|
||||||
))]
|
))]
|
||||||
|
|
||||||
|
# Allocated quantity cannot cause the stock item to be over-allocated
|
||||||
if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity:
|
if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity:
|
||||||
errors['quantity'] = _('StockItem is over-allocated')
|
errors['quantity'] = _('StockItem is over-allocated')
|
||||||
|
|
||||||
|
# Allocated quantity must be positive
|
||||||
if self.quantity <= 0:
|
if self.quantity <= 0:
|
||||||
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
||||||
|
|
||||||
if self.stock_item.serial and not self.quantity == 1:
|
# Quantity must be 1 for serialized stock
|
||||||
|
if self.stock_item.serialized and not self.quantity == 1:
|
||||||
errors['quantity'] = _('Quantity must be 1 for serialized stock')
|
errors['quantity'] = _('Quantity must be 1 for serialized stock')
|
||||||
|
|
||||||
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
|
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
|
||||||
@ -540,22 +909,33 @@ class BuildItem(models.Model):
|
|||||||
if len(errors) > 0:
|
if len(errors) > 0:
|
||||||
raise ValidationError(errors)
|
raise ValidationError(errors)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def complete_allocation(self, user):
|
def complete_allocation(self, user):
|
||||||
|
"""
|
||||||
|
Complete the allocation of this BuildItem into the output stock item.
|
||||||
|
|
||||||
|
- If the referenced part is trackable, the stock item will be *installed* into the build output
|
||||||
|
- If the referenced part is *not* trackable, the stock item will be removed from stock
|
||||||
|
"""
|
||||||
|
|
||||||
item = self.stock_item
|
item = self.stock_item
|
||||||
|
|
||||||
|
# For a trackable part, special consideration needed!
|
||||||
|
if item.part.trackable:
|
||||||
# Split the allocated stock if there are more available than allocated
|
# Split the allocated stock if there are more available than allocated
|
||||||
if item.quantity > self.quantity:
|
if item.quantity > self.quantity:
|
||||||
item = item.splitStock(self.quantity, None, user)
|
item = item.splitStock(self.quantity, None, user)
|
||||||
|
|
||||||
# Update our own reference to the new item
|
# Make sure we are pointing to the new item
|
||||||
self.stock_item = item
|
self.stock_item = item
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
# TODO - If the item__part object is not trackable, delete the stock item here
|
# Install the stock item into the output
|
||||||
|
item.belongs_to = self.install_into
|
||||||
item.build_order = self.build
|
|
||||||
item.save()
|
item.save()
|
||||||
|
else:
|
||||||
|
# Simply remove the items from stock
|
||||||
|
item.take_stock(self.quantity, user)
|
||||||
|
|
||||||
build = models.ForeignKey(
|
build = models.ForeignKey(
|
||||||
Build,
|
Build,
|
||||||
@ -568,7 +948,7 @@ class BuildItem(models.Model):
|
|||||||
'stock.StockItem',
|
'stock.StockItem',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='allocations',
|
related_name='allocations',
|
||||||
help_text=_('Stock Item to allocate to build'),
|
help_text=_('Source stock item'),
|
||||||
limit_choices_to={
|
limit_choices_to={
|
||||||
'build_order': None,
|
'build_order': None,
|
||||||
'sales_order': None,
|
'sales_order': None,
|
||||||
@ -583,3 +963,14 @@ class BuildItem(models.Model):
|
|||||||
validators=[MinValueValidator(0)],
|
validators=[MinValueValidator(0)],
|
||||||
help_text=_('Stock quantity to allocate to build')
|
help_text=_('Stock quantity to allocate to build')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
install_into = models.ForeignKey(
|
||||||
|
'stock.StockItem',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
blank=True, null=True,
|
||||||
|
related_name='items_to_install',
|
||||||
|
help_text=_('Destination stock item'),
|
||||||
|
limit_choices_to={
|
||||||
|
'is_building': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ -38,6 +38,7 @@ class BuildSerializer(InvenTreeModelSerializer):
|
|||||||
'url',
|
'url',
|
||||||
'title',
|
'title',
|
||||||
'creation_date',
|
'creation_date',
|
||||||
|
'completed',
|
||||||
'completion_date',
|
'completion_date',
|
||||||
'part',
|
'part',
|
||||||
'part_detail',
|
'part_detail',
|
||||||
@ -51,9 +52,10 @@ class BuildSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'status',
|
'completed',
|
||||||
'creation_date',
|
'creation_date',
|
||||||
'completion_data',
|
'completion_data',
|
||||||
|
'status',
|
||||||
'status_text',
|
'status_text',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -73,6 +75,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
'build',
|
'build',
|
||||||
|
'install_into',
|
||||||
'part',
|
'part',
|
||||||
'part_name',
|
'part_name',
|
||||||
'part_image',
|
'part_image',
|
||||||
|
@ -11,404 +11,68 @@ InvenTree | Allocate Parts
|
|||||||
|
|
||||||
{% include "build/tabs.html" with tab='allocate' %}
|
{% include "build/tabs.html" with tab='allocate' %}
|
||||||
|
|
||||||
<div id='build-item-toolbar'>
|
<h4>{% trans "Incomplete Build Ouputs" %}</h4>
|
||||||
{% if build.status == BuildStatus.PENDING %}
|
|
||||||
<div class='btn-group'>
|
<hr>
|
||||||
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'>{% trans "Order Parts" %}</button>
|
|
||||||
<button class='btn btn-primary' type='button' id='btn-allocate' title='{% trans "Automatically allocate stock" %}'>{% trans "Auto Allocate" %}</button>
|
{% if build.is_complete %}
|
||||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='Unallocate Stock'>{% trans "Unallocate" %}</button>
|
<div class='alert alert-block alert-success'>
|
||||||
</div>
|
{% trans "Build order has been completed" %}
|
||||||
{% endif %}
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
<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>
|
||||||
|
<!--
|
||||||
|
<button class='btn btn-primary' type='button' id='btn-order-parts' title='{% trans "Order required parts" %}'>
|
||||||
|
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
|
||||||
|
</button>
|
||||||
|
-->
|
||||||
|
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||||
|
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' id='build-item-list' data-toolbar='#build-item-toolbar'></table>
|
<hr>
|
||||||
|
{% 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 %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
<b>{% trans "Create a new build output" %}</b><br>
|
||||||
|
{% trans "No incomplete build outputs remain." %}<br>
|
||||||
|
{% trans "Create a new build output using the button above" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
var buildTable = $("#build-item-list");
|
var buildInfo = {
|
||||||
|
pk: {{ build.pk }},
|
||||||
|
quantity: {{ build.quantity }},
|
||||||
|
completed: {{ build.completed }},
|
||||||
|
part: {{ build.part.pk }},
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate sum of allocations for a particular table row
|
{% for item in build.incomplete_outputs %}
|
||||||
function sumAllocations(row) {
|
// Get the build output as a javascript object
|
||||||
if (row.allocations == null) {
|
inventreeGet('{% url 'api-stock-detail' item.pk %}', {},
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
var quantity = 0;
|
|
||||||
|
|
||||||
row.allocations.forEach(function(item) {
|
|
||||||
quantity += item.quantity;
|
|
||||||
});
|
|
||||||
|
|
||||||
return quantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUnallocated(row) {
|
|
||||||
// Return the number of items remaining to be allocated for a given row
|
|
||||||
return {{ build.quantity }} * row.quantity - sumAllocations(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setExpandedAllocatedLocation(row) {
|
|
||||||
// Handle case when stock item does not have a location set
|
|
||||||
if (row.location_detail == null) {
|
|
||||||
return 'No stock location set';
|
|
||||||
} else {
|
|
||||||
return row.location_detail.pathstring;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reloadTable() {
|
|
||||||
// Reload the build allocation table
|
|
||||||
buildTable.bootstrapTable('refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupCallbacks() {
|
|
||||||
// Register button callbacks once the table data are loaded
|
|
||||||
|
|
||||||
buildTable.find(".button-add").click(function() {
|
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
|
|
||||||
// Extract row data from the table
|
|
||||||
var idx = $(this).closest('tr').attr('data-index');
|
|
||||||
var row = buildTable.bootstrapTable('getData')[idx];
|
|
||||||
|
|
||||||
launchModalForm('/build/item/new/', {
|
|
||||||
success: reloadTable,
|
|
||||||
data: {
|
|
||||||
part: row.sub_part,
|
|
||||||
build: {{ build.id }},
|
|
||||||
quantity: getUnallocated(row),
|
|
||||||
},
|
|
||||||
secondary: [
|
|
||||||
{
|
{
|
||||||
field: 'stock_item',
|
success: function(response) {
|
||||||
label: '{% trans "New Stock Item" %}',
|
loadBuildOutputAllocationTable(buildInfo, response);
|
||||||
title: '{% trans "Create new Stock Item" %}',
|
|
||||||
url: '{% url "stock-item-create" %}',
|
|
||||||
data: {
|
|
||||||
part: row.sub_part,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
buildTable.find(".button-build").click(function() {
|
|
||||||
// Start a new build for the sub_part
|
|
||||||
|
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
|
|
||||||
// Extract row data from the table
|
|
||||||
var idx = $(this).closest('tr').attr('data-index');
|
|
||||||
var row = buildTable.bootstrapTable('getData')[idx];
|
|
||||||
|
|
||||||
launchModalForm('/build/new/', {
|
|
||||||
follow: true,
|
|
||||||
data: {
|
|
||||||
part: row.sub_part,
|
|
||||||
parent: {{ build.id }},
|
|
||||||
quantity: getUnallocated(row),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
buildTable.find(".button-buy").click(function() {
|
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
|
|
||||||
// Extract row data from the table
|
|
||||||
var idx = $(this).closest('tr').attr('data-index');
|
|
||||||
var row = buildTable.bootstrapTable('getData')[idx];
|
|
||||||
|
|
||||||
launchModalForm("{% url 'order-parts' %}", {
|
|
||||||
data: {
|
|
||||||
parts: [row.sub_part],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTable.inventreeTable({
|
|
||||||
uniqueId: 'sub_part',
|
|
||||||
url: "{% url 'api-bom-list' %}",
|
|
||||||
onPostBody: setupCallbacks,
|
|
||||||
detailViewByClick: true,
|
|
||||||
detailView: true,
|
|
||||||
detailFilter: function(index, row) {
|
|
||||||
return row.allocations != null;
|
|
||||||
},
|
|
||||||
detailFormatter: function(index, row, element) {
|
|
||||||
// Construct an 'inner table' which shows the stock allocations
|
|
||||||
|
|
||||||
var subTableId = `allocation-table-${row.pk}`;
|
|
||||||
|
|
||||||
var html = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
|
|
||||||
|
|
||||||
element.html(html);
|
|
||||||
|
|
||||||
var lineItem = row;
|
|
||||||
|
|
||||||
var subTable = $(`#${subTableId}`);
|
|
||||||
|
|
||||||
subTable.bootstrapTable({
|
|
||||||
data: row.allocations,
|
|
||||||
showHeader: true,
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
width: '50%',
|
|
||||||
field: 'quantity',
|
|
||||||
title: 'Quantity',
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
var text = '';
|
|
||||||
|
|
||||||
var url = '';
|
|
||||||
|
|
||||||
if (row.serial && row.quantity == 1) {
|
|
||||||
text = `{% trans "Serial Number" %}: ${row.serial}`;
|
|
||||||
} else {
|
|
||||||
text = `{% trans "Quantity" %}: ${row.quantity}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if build.status == BuildStatus.COMPLETE %}
|
|
||||||
url = `/stock/item/${row.pk}/`;
|
|
||||||
{% else %}
|
|
||||||
url = `/stock/item/${row.stock_item}/`;
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
return renderLink(text, url);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'location',
|
|
||||||
title: '{% trans "Location" %}',
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
{% if build.status == BuildStatus.COMPLETE %}
|
|
||||||
var text = setExpandedAllocatedLocation(row);
|
|
||||||
var url = `/stock/location/${row.location}/`;
|
|
||||||
{% else %}
|
|
||||||
var text = row.stock_item_detail.location_name;
|
|
||||||
var url = `/stock/location/${row.stock_item_detail.location}/`;
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
return renderLink(text, url);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{% if build.status == BuildStatus.PENDING %}
|
|
||||||
{
|
|
||||||
field: 'buttons',
|
|
||||||
title: 'Actions',
|
|
||||||
formatter: function(value, row) {
|
|
||||||
|
|
||||||
var pk = row.pk;
|
|
||||||
|
|
||||||
var html = `<div class='btn-group float-right' role='group'>`;
|
|
||||||
|
|
||||||
{% if build.status == BuildStatus.PENDING %}
|
|
||||||
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
|
|
||||||
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
html += `</div>`;
|
|
||||||
|
|
||||||
return html;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{% endif %}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assign button callbacks to the newly created allocation buttons
|
|
||||||
subTable.find(".button-allocation-edit").click(function() {
|
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
launchModalForm(`/build/item/${pk}/edit/`, {
|
|
||||||
success: reloadTable,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
subTable.find('.button-allocation-delete').click(function() {
|
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
launchModalForm(`/build/item/${pk}/delete/`, {
|
|
||||||
success: reloadTable,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
formatNoMatches: function() { return "{% trans 'No BOM items found' %}"; },
|
|
||||||
onLoadSuccess: function(tableData) {
|
|
||||||
// Once the BOM data are loaded, request allocation data for the build
|
|
||||||
{% if build.status == BuildStatus.COMPLETE %}
|
|
||||||
// Request StockItem which have been assigned to this build
|
|
||||||
inventreeGet('/api/stock/',
|
|
||||||
{
|
|
||||||
build_order: {{ build.id }},
|
|
||||||
location_detail: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
success: function(data) {
|
|
||||||
// Iterate through the returned data, group by "part",
|
|
||||||
var allocations = {};
|
|
||||||
|
|
||||||
data.forEach(function(item) {
|
|
||||||
// Group allocations by referenced 'part'
|
|
||||||
var key = parseInt(item.part);
|
|
||||||
|
|
||||||
if (!(key in allocations)) {
|
|
||||||
allocations[key] = new Array();
|
|
||||||
}
|
|
||||||
|
|
||||||
allocations[key].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (var key in allocations) {
|
|
||||||
|
|
||||||
var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key);
|
|
||||||
|
|
||||||
tableRow.allocations = allocations[key];
|
|
||||||
|
|
||||||
buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
{% endfor %}
|
||||||
{% else %}
|
|
||||||
inventreeGet('/api/build/item/',
|
|
||||||
{
|
|
||||||
build: {{ build.id }},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
success: function(data) {
|
|
||||||
|
|
||||||
// Iterate through the returned data, and group by "part"
|
|
||||||
var allocations = {};
|
|
||||||
|
|
||||||
data.forEach(function(item) {
|
|
||||||
|
|
||||||
// Group allocations by referenced 'part'
|
|
||||||
var part = item.part;
|
|
||||||
var key = parseInt(part);
|
|
||||||
|
|
||||||
if (!(key in allocations)) {
|
|
||||||
allocations[key] = new Array();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the allocation to the list
|
|
||||||
allocations[key].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (var key in allocations) {
|
|
||||||
|
|
||||||
// Select the associated row in the table
|
|
||||||
var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key);
|
|
||||||
|
|
||||||
// Set the allocations for the row
|
|
||||||
tableRow.allocations = allocations[key];
|
|
||||||
|
|
||||||
// And push the updated row back into the main table
|
|
||||||
buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
{% endif %}
|
|
||||||
},
|
|
||||||
queryParams: {
|
|
||||||
part: {{ build.part.id }},
|
|
||||||
sub_part_detail: 1,
|
|
||||||
},
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
field: 'id',
|
|
||||||
visible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
field: 'sub_part',
|
|
||||||
title: '{% trans "Part" %}',
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
return imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, `/part/${row.sub_part}/`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
field: 'sub_part_detail.description',
|
|
||||||
title: '{% trans "Description" %}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
field: 'reference',
|
|
||||||
title: '{% trans "Reference" %}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
field: 'quantity',
|
|
||||||
title: '{% trans "Required" %}',
|
|
||||||
formatter: function(value, row) {
|
|
||||||
return value * {{ build.quantity }};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sortable: true,
|
|
||||||
field: 'allocated',
|
|
||||||
{% if build.status == BuildStatus.COMPLETE %}
|
|
||||||
title: '{% trans "Assigned" %}',
|
|
||||||
{% else %}
|
|
||||||
title: '{% trans "Allocated" %}',
|
|
||||||
{% endif %}
|
|
||||||
formatter: function(value, row) {
|
|
||||||
|
|
||||||
var allocated = sumAllocations(row);
|
|
||||||
|
|
||||||
return makeProgressBar(allocated, row.quantity * {{ build.quantity }});
|
|
||||||
},
|
|
||||||
sorter: function(valA, valB, rowA, rowB) {
|
|
||||||
|
|
||||||
var aA = sumAllocations(rowA);
|
|
||||||
var aB = sumAllocations(rowB);
|
|
||||||
|
|
||||||
var qA = rowA.quantity * {{ build.quantity }};
|
|
||||||
var qB = rowB.quantity * {{ build.quantity }};
|
|
||||||
|
|
||||||
if (aA == 0 && aB == 0) {
|
|
||||||
return (qA > qB) ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
var progressA = parseFloat(aA) / qA;
|
|
||||||
var progressB = parseFloat(aB) / qB;
|
|
||||||
|
|
||||||
return (progressA < progressB) ? 1 : -1;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{% if build.status == BuildStatus.PENDING %}
|
|
||||||
{
|
|
||||||
field: 'buttons',
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
|
|
||||||
var html = `<div class='btn-group float-right' role='group'>`;
|
|
||||||
var pk = row.sub_part;
|
|
||||||
|
|
||||||
{% if build.status == BuildStatus.PENDING %}
|
|
||||||
if (row.sub_part_detail.purchaseable) {
|
|
||||||
html += makeIconButton('fa-shopping-cart', 'button-buy', pk, '{% trans "Buy parts" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.sub_part_detail.assembly) {
|
|
||||||
html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
return html;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
{% endif %}
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
{% if build.status == BuildStatus.PENDING %}
|
{% if build.status == BuildStatus.PENDING %}
|
||||||
$("#btn-allocate").on('click', function() {
|
$("#btn-allocate").on('click', function() {
|
||||||
@ -424,7 +88,15 @@ InvenTree | Allocate Parts
|
|||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'build-unallocate' build.id %}",
|
"{% url 'build-unallocate' build.id %}",
|
||||||
{
|
{
|
||||||
success: reloadTable,
|
reload: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btn-create-output').click(function() {
|
||||||
|
launchModalForm('{% url "build-output-create" build.id %}',
|
||||||
|
{
|
||||||
|
reload: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
43
InvenTree/build/templates/build/allocation_card.html
Normal file
43
InvenTree/build/templates/build/allocation_card.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{% 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'>
|
||||||
|
<a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}">
|
||||||
|
<div class='col-sm-4'>
|
||||||
|
<span class='fas fa-caret-right'></span>
|
||||||
|
{{ item.part.full_name }}
|
||||||
|
</div>
|
||||||
|
<div class='col-sm-2'>
|
||||||
|
{% if item.serial %}
|
||||||
|
# {{ item.serial }}
|
||||||
|
{% else %}
|
||||||
|
{% decimal item.quantity %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class='col-sm-3'>
|
||||||
|
<div>
|
||||||
|
<div id='output-progress-{{ pk }}'>
|
||||||
|
<span class='fas fa-spin fa-spinner'></span>
|
||||||
|
</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>
|
78
InvenTree/build/templates/build/attachments.html
Normal file
78
InvenTree/build/templates/build/attachments.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{% extends "build/build_base.html" %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load markdownify %}
|
||||||
|
|
||||||
|
{% block details %}
|
||||||
|
|
||||||
|
{% include "build/tabs.html" with tab='attachments' %}
|
||||||
|
|
||||||
|
<h4>{% trans "Attachments" %}</h4>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{% include "attachment_table.html" with attachments=build.attachments.all %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
enableDragAndDrop(
|
||||||
|
'#attachment-dropzone',
|
||||||
|
'{% url "build-attachment-create" %}',
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
build: {{ build.id }},
|
||||||
|
},
|
||||||
|
label: 'attachment',
|
||||||
|
success: function(data, status, xhr) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Callback for creating a new attachment
|
||||||
|
$('#new-attachment').click(function() {
|
||||||
|
launchModalForm(
|
||||||
|
'{% url "build-attachment-create" %}',
|
||||||
|
{
|
||||||
|
reload: true,
|
||||||
|
data: {
|
||||||
|
build: {{ build.pk }},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for editing an attachment
|
||||||
|
$("#attachment-table").on('click', '.attachment-edit-button', function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
var url = `/build/attachment/${pk}/edit/`;
|
||||||
|
|
||||||
|
launchModalForm(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
reload: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for deleting an attachment
|
||||||
|
$("#attachment-table").on('click', '.attachment-delete-button', function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
var url = `/build/attachment/${pk}/delete/`;
|
||||||
|
|
||||||
|
launchModalForm(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
reload: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#attachment-table").inventreeTable({});
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -6,13 +6,10 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
<b>{% trans "Automatically Allocate Stock" %}</b><br>
|
<b>{% trans "Automatically Allocate Stock" %}</b><br>
|
||||||
{% trans "Stock Items are selected for automatic allocation if there is only a single stock item available." %}<br>
|
{% trans "The following stock items will be allocated to the specified build output" %}
|
||||||
{% trans "The following stock items will be allocated to the build:" %}<br>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if allocations %}
|
{% if allocations %}
|
||||||
|
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
|
@ -68,11 +68,6 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
|
|
||||||
<h4>{% trans "Build Details" %}</h4>
|
<h4>{% trans "Build Details" %}</h4>
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tr>
|
|
||||||
<td><span class='fas fa-hashtag'></span></td>
|
|
||||||
<td>{% trans "Build Order Reference" %}</td>
|
|
||||||
<td>{{ build }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-shapes'></span></td>
|
<td><span class='fas fa-shapes'></span></td>
|
||||||
<td>{% trans "Part" %}</td>
|
<td>{% trans "Part" %}</td>
|
||||||
@ -88,6 +83,11 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<td>{% trans "Status" %}</td>
|
<td>{% trans "Status" %}</td>
|
||||||
<td>{% build_status_label build.status %}</td>
|
<td>{% build_status_label build.status %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-spinner'></span></td>
|
||||||
|
<td>{% trans "Progress" %}</td>
|
||||||
|
<td> {{ build.completed }} / {{ build.quantity }}</td>
|
||||||
|
</tr>
|
||||||
{% if build.parent %}
|
{% if build.parent %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-sitemap'></span></td>
|
<td><span class='fas fa-sitemap'></span></td>
|
||||||
@ -102,20 +102,6 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td>
|
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
|
||||||
<td><span class='fas fa-dollar-sign'></span></td>
|
|
||||||
<td>{% trans "BOM Price" %}</td>
|
|
||||||
<td>
|
|
||||||
{% if bom_price %}
|
|
||||||
{{ bom_price }}
|
|
||||||
{% if build.part.has_complete_bom_pricing == False %}
|
|
||||||
<br><span class='warning-msg'><i>{% trans "BOM pricing is incomplete" %}</i></span>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<span class='warning-msg'><i>{% trans "No pricing information" %}</i></span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
20
InvenTree/build/templates/build/build_output_create.html
Normal file
20
InvenTree/build/templates/build/build_output_create.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block pre_form_content %}
|
||||||
|
|
||||||
|
{% if build.part.has_trackable_parts %}
|
||||||
|
<div class='alert alert-block alert-warning'>
|
||||||
|
{% trans "The Bill of Materials contains trackable parts" %}<br>
|
||||||
|
{% trans "Build outputs must be generated individually." %}<br>
|
||||||
|
{% trans "Multiple build outputs will be created based on the quantity specified." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if build.part.trackable %}
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
{% trans "Trackable parts can have serial numbers specified" %}<br>
|
||||||
|
{% trans "Enter serial numbers to generate multiple single build outputs" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -1,7 +1,7 @@
|
|||||||
{% extends "modal_form.html" %}
|
{% extends "modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
Are you sure you wish to cancel this build?
|
{% trans "Are you sure you wish to cancel this build?" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -3,35 +3,21 @@
|
|||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
<h4>{% trans "Build" %} - {{ build }}</h4>
|
{% if build.can_complete %}
|
||||||
|
<div class='alert alert-block alert-success'>
|
||||||
{% if build.isFullyAllocated %}
|
{% trans "Build can be completed" %}
|
||||||
<div class='alert alert-block alert-info'>
|
|
||||||
<h4>{% trans "Build order allocation is complete" %}</h4>
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class='alert alert-block alert-danger'>
|
<div class='alert alert-block alert-danger'>
|
||||||
<h4>{% trans "Warning: Build order allocation is not complete" %}</h4>
|
<b>{% trans "Build cannot be completed" %}</b><br>
|
||||||
{% trans "Build Order has not been fully allocated. Ensure that all Stock Items have been allocated to the Build" %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class='alert alert-block alert-success'>
|
|
||||||
<h4>{% trans "The following actions will be performed:" %}</h4>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>{% trans "Remove allocated items from stock" %}</li>
|
{% if build.incomplete_count > 0 %}
|
||||||
<li>{% trans "Add completed items to stock" %}</li>
|
<li>{% trans "Incompleted build outputs remain" %}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if build.completed < build.quantity %}
|
||||||
|
<li>{% trans "Required build quantity has not been completed" %}</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class='panel panel-default'>
|
|
||||||
<div class='panel-heading'>
|
|
||||||
{% trans "The following items will be created" %}
|
|
||||||
</div>
|
|
||||||
<div class='panel-content'>
|
|
||||||
{% include "hover_image.html" with image=build.part.image hover=True %}
|
|
||||||
{{ build.quantity }} x {{ build.part.full_name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
48
InvenTree/build/templates/build/complete_output.html
Normal file
48
InvenTree/build/templates/build/complete_output.html
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{% extends "modal_form.html" %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block pre_form_content %}
|
||||||
|
|
||||||
|
{% if fully_allocated %}
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
<h4>{% trans "Stock allocation is complete" %}</h4>
|
||||||
|
</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 "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'>
|
||||||
|
{% include "hover_image.html" with image=build.part.image hover=True %}
|
||||||
|
{% decimal output.quantity %} x {{ output.part.full_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -1,9 +1,22 @@
|
|||||||
{% extends "modal_form.html" %}
|
{% extends "modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
<p>
|
||||||
|
{% trans "Select a stock item to allocate to the selected build output" %}
|
||||||
|
</p>
|
||||||
|
{% if output %}
|
||||||
|
<p>
|
||||||
|
{% trans "The allocated stock will be installed into the following build output:" %}
|
||||||
|
<br>
|
||||||
|
<i>{{ output }}</i>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% if no_stock %}
|
{% if no_stock %}
|
||||||
<div class='alert alert-danger alert-block' role='alert'>
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
No stock available for {{ part }}
|
{% trans "No stock available for" %} {{ part }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -3,7 +3,12 @@
|
|||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
{% trans "Are you sure you want to unallocate these parts?" %}
|
<div class='alert alert-block alert-danger'>
|
||||||
<br>
|
<p>
|
||||||
This will remove {% decimal item.quantity %} parts from build '{{ item.build.title }}'.
|
{% trans "Are you sure you want to unallocate this stock?" %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans "The selected stock will be unallocated from the build output" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -10,77 +10,120 @@
|
|||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<table class='table table-striped'>
|
<div class='row'>
|
||||||
|
<div class='col-sm-6'>
|
||||||
|
<table class='table table-striped'>
|
||||||
<col width='25'>
|
<col width='25'>
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td><span class='fas fa-info'></span></td>
|
||||||
<td>{% trans "Title" %}</td>
|
<td>{% trans "Description" %}</td>
|
||||||
<td>{{ build.title }}</td>
|
<td>{{ build.title }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-shapes'></span></td>
|
<td><span class='fas fa-shapes'></span></td>
|
||||||
<td>{% trans "Part" %}</td>
|
<td>{% trans "Part" %}</td>
|
||||||
<td><a href="{% url 'part-build' build.part.id %}">{{ build.part.full_name }}</a></td>
|
<td><a href="{% url 'part-build' build.part.id %}">{{ build.part.full_name }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td>{% trans "Quantity" %}</td><td>{{ build.quantity }}</td>
|
<td>{% trans "Quantity" %}</td><td>{{ build.quantity }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-map-marker-alt'></span></td>
|
<td><span class='fas fa-map-marker-alt'></span></td>
|
||||||
<td>{% trans "Stock Source" %}</td>
|
<td>{% trans "Stock Source" %}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if build.take_from %}
|
{% if build.take_from %}
|
||||||
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>
|
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "Stock can be taken from any available location." %}
|
<i>{% trans "Stock can be taken from any available location." %}</i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><span class='fas fa-map-marker-alt'></span></td>
|
||||||
|
<td>{% trans "Destination" %}</td>
|
||||||
|
<td>
|
||||||
|
{% if build.destination %}
|
||||||
|
<a href="{% url 'stock-location-detail' build.destination.id %}">
|
||||||
|
{{ build.destination }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<i>{% trans "Destination location not specified" %}</i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td><span class='fas fa-info'></span></td>
|
<td><span class='fas fa-info'></span></td>
|
||||||
<td>{% trans "Status" %}</td>
|
<td>{% trans "Status" %}</td>
|
||||||
<td>{% build_status_label build.status %}</td>
|
<td>{% build_status_label build.status %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if build.batch %}
|
<tr>
|
||||||
<tr>
|
<td><span class='fas fa-spinner'></span></td>
|
||||||
<td></td>
|
<td>{% trans "Progress" %}</td>
|
||||||
|
<td>{{ build.completed }} / {{ build.quantity }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if build.batch %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-layer-group'></span></td>
|
||||||
<td>{% trans "Batch" %}</td>
|
<td>{% trans "Batch" %}</td>
|
||||||
<td>{{ build.batch }}</td>
|
<td>{{ build.batch }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if build.link %}
|
{% if build.parent %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><span class='fas fa-sitemap'></span></td>
|
||||||
|
<td>{% trans "Parent Build" %}</td>
|
||||||
|
<td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if build.sales_order %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-dolly'></span></td>
|
||||||
|
<td>{% trans "Sales Order" %}</td>
|
||||||
|
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if build.link %}
|
||||||
|
<tr>
|
||||||
<td><span class='fas fa-link'></span></td>
|
<td><span class='fas fa-link'></span></td>
|
||||||
<td>{% trans "External Link" %}</td>
|
<td>{% trans "External Link" %}</td>
|
||||||
<td><a href="{{ build.link }}">{{ build.link }}</a></td>
|
<td><a href="{{ build.link }}">{{ build.link }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-calendar-alt'></span></td>
|
<td><span class='fas fa-calendar-alt'></span></td>
|
||||||
<td>{% trans "Created" %}</td>
|
<td>{% trans "Created" %}</td>
|
||||||
<td>{{ build.creation_date }}</td>
|
<td>{{ build.creation_date }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if build.is_active %}
|
</table>
|
||||||
<tr>
|
</div>
|
||||||
<td></td>
|
<div class='col-sm-6'>
|
||||||
<td>{% trans "Enough Parts?" %}</td>
|
<table class='table table-striped'>
|
||||||
|
<col width='25'>
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-dollar-sign'></span></td>
|
||||||
|
<td>{% trans "BOM Price" %}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if build.can_build %}
|
{% if bom_price %}
|
||||||
{% trans "Yes" %}
|
{{ bom_price }}
|
||||||
|
{% if build.part.has_complete_bom_pricing == False %}
|
||||||
|
<br><span class='warning-msg'><i>{% trans "BOM pricing is incomplete" %}</i></span>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "No" %}
|
<span class='warning-msg'><i>{% trans "No pricing information" %}</i></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% if build.completion_date %}
|
||||||
{% if build.completion_date %}
|
<tr>
|
||||||
<tr>
|
|
||||||
<td><span class='fas fa-calendar-alt'></span></td>
|
<td><span class='fas fa-calendar-alt'></span></td>
|
||||||
<td>{% trans "Completed" %}</td>
|
<td>{% trans "Completed" %}</td>
|
||||||
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
|
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
10
InvenTree/build/templates/build/edit_build_item.html
Normal file
10
InvenTree/build/templates/build/edit_build_item.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{% 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 %}
|
@ -41,12 +41,7 @@ InvenTree | {% trans "Build Orders" %}
|
|||||||
$("#collapse-item-active").collapse().show();
|
$("#collapse-item-active").collapse().show();
|
||||||
|
|
||||||
$("#new-build").click(function() {
|
$("#new-build").click(function() {
|
||||||
launchModalForm(
|
newBuildOrder();
|
||||||
"{% url 'build-create' %}",
|
|
||||||
{
|
|
||||||
follow: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
loadBuildTable($("#build-table"), {
|
loadBuildTable($("#build-table"), {
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
|
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
|
||||||
</li>
|
</li>
|
||||||
<li{% if tab == 'allocate' %} class='active'{% endif %}>
|
<li{% if tab == 'allocate' %} class='active'{% endif %}>
|
||||||
<a href="{% url 'build-allocate' build.id %}">{% trans "Allocated Parts" %}</a>
|
<a href="{% url 'build-allocate' build.id %}">{% trans "Allocate Parts" %}</a>
|
||||||
</li>
|
</li>
|
||||||
<li{% if tab == 'output' %} class='active'{% endif %}>
|
<li{% if tab == 'output' %} class='active'{% endif %}>
|
||||||
<a href="{% url 'build-output' build.id %}">{% trans "Build Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
|
<a href="{% url 'build-output' build.id %}">{% trans "Build Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
|
||||||
@ -13,4 +13,7 @@
|
|||||||
<li{% if tab == 'notes' %} class='active'{% endif %}>
|
<li{% if tab == 'notes' %} class='active'{% endif %}>
|
||||||
<a href="{% url 'build-notes' build.id %}">{% trans "Notes" %}{% if build.notes %} <span class='fas fa-info-circle'></span>{% endif %}</a>
|
<a href="{% url 'build-notes' build.id %}">{% trans "Notes" %}{% if build.notes %} <span class='fas fa-info-circle'></span>{% endif %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li {% if tab == 'attachments' %} class='active'{% endif %}>
|
||||||
|
<a href='{% url "build-attachments" build.id %}'>{% trans "Attachments" %}</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
@ -5,6 +5,11 @@
|
|||||||
|
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
{% trans "Are you sure you wish to unallocate all stock for this build?" %}
|
|
||||||
|
<div class='alert alert-block alert-danger'>
|
||||||
|
{% trans "Are you sure you wish to unallocate all stock for this build?" %}
|
||||||
|
<br>
|
||||||
|
{% trans "All incomplete stock allocations will be removed from the build" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -3,7 +3,6 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
|
|
||||||
from build.models import Build, BuildItem
|
from build.models import Build, BuildItem
|
||||||
@ -11,8 +10,6 @@ from stock.models import StockItem
|
|||||||
from part.models import Part, BomItem
|
from part.models import Part, BomItem
|
||||||
from InvenTree import status_codes as status
|
from InvenTree import status_codes as status
|
||||||
|
|
||||||
from InvenTree.helpers import ExtractSerialNumbers
|
|
||||||
|
|
||||||
|
|
||||||
class BuildTest(TestCase):
|
class BuildTest(TestCase):
|
||||||
"""
|
"""
|
||||||
@ -64,6 +61,21 @@ class BuildTest(TestCase):
|
|||||||
quantity=10
|
quantity=10
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create some build output (StockItem) objects
|
||||||
|
self.output_1 = StockItem.objects.create(
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=5,
|
||||||
|
is_building=True,
|
||||||
|
build=self.build
|
||||||
|
)
|
||||||
|
|
||||||
|
self.output_2 = StockItem.objects.create(
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=5,
|
||||||
|
is_building=True,
|
||||||
|
build=self.build,
|
||||||
|
)
|
||||||
|
|
||||||
# Create some stock items to assign to the build
|
# Create some stock items to assign to the build
|
||||||
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=1000)
|
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=1000)
|
||||||
self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100)
|
self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100)
|
||||||
@ -73,23 +85,28 @@ class BuildTest(TestCase):
|
|||||||
def test_init(self):
|
def test_init(self):
|
||||||
# Perform some basic tests before we start the ball rolling
|
# Perform some basic tests before we start the ball rolling
|
||||||
|
|
||||||
self.assertEqual(StockItem.objects.count(), 3)
|
self.assertEqual(StockItem.objects.count(), 5)
|
||||||
|
|
||||||
|
# Build is PENDING
|
||||||
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
|
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
|
||||||
self.assertFalse(self.build.isFullyAllocated())
|
|
||||||
|
|
||||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1))
|
# Build has two build outputs
|
||||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2))
|
self.assertEqual(self.build.output_count, 2)
|
||||||
|
|
||||||
self.assertEqual(self.build.getRequiredQuantity(self.sub_part_1), 100)
|
# None of the build outputs have been completed
|
||||||
self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250)
|
for output in self.build.get_build_outputs().all():
|
||||||
|
self.assertFalse(self.build.isFullyAllocated(output))
|
||||||
|
|
||||||
|
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
|
||||||
|
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
|
||||||
|
|
||||||
|
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 50)
|
||||||
|
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 50)
|
||||||
|
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 125)
|
||||||
|
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 125)
|
||||||
|
|
||||||
self.assertTrue(self.build.can_build)
|
|
||||||
self.assertFalse(self.build.is_complete)
|
self.assertFalse(self.build.is_complete)
|
||||||
|
|
||||||
# Delete some stock and see if the build can still be completed
|
|
||||||
self.stock_2_1.delete()
|
|
||||||
self.assertFalse(self.build.can_build)
|
|
||||||
|
|
||||||
def test_build_item_clean(self):
|
def test_build_item_clean(self):
|
||||||
# Ensure that dodgy BuildItem objects cannot be created
|
# Ensure that dodgy BuildItem objects cannot be created
|
||||||
|
|
||||||
@ -99,7 +116,7 @@ class BuildTest(TestCase):
|
|||||||
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
|
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
b.clean()
|
b.save()
|
||||||
|
|
||||||
# Create a BuildItem which has too much stock assigned
|
# Create a BuildItem which has too much stock assigned
|
||||||
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999)
|
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999)
|
||||||
@ -113,6 +130,10 @@ class BuildTest(TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
b.clean()
|
b.clean()
|
||||||
|
|
||||||
|
# Ok, what about we make one that does *not* fail?
|
||||||
|
b = BuildItem(stock_item=self.stock_1_1, build=self.build, install_into=self.output_1, quantity=10)
|
||||||
|
b.save()
|
||||||
|
|
||||||
def test_duplicate_bom_line(self):
|
def test_duplicate_bom_line(self):
|
||||||
# Try to add a duplicate BOM item - it should fail!
|
# Try to add a duplicate BOM item - it should fail!
|
||||||
|
|
||||||
@ -123,75 +144,120 @@ class BuildTest(TestCase):
|
|||||||
quantity=99
|
quantity=99
|
||||||
)
|
)
|
||||||
|
|
||||||
def allocate_stock(self, q11, q12, q21):
|
def allocate_stock(self, q11, q12, q21, output):
|
||||||
# Assign stock to this build
|
# Assign stock to this build
|
||||||
|
|
||||||
|
if q11 > 0:
|
||||||
BuildItem.objects.create(
|
BuildItem.objects.create(
|
||||||
build=self.build,
|
build=self.build,
|
||||||
stock_item=self.stock_1_1,
|
stock_item=self.stock_1_1,
|
||||||
quantity=q11
|
quantity=q11,
|
||||||
|
install_into=output
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if q12 > 0:
|
||||||
BuildItem.objects.create(
|
BuildItem.objects.create(
|
||||||
build=self.build,
|
build=self.build,
|
||||||
stock_item=self.stock_1_2,
|
stock_item=self.stock_1_2,
|
||||||
quantity=q12
|
quantity=q12,
|
||||||
|
install_into=output
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if q21 > 0:
|
||||||
BuildItem.objects.create(
|
BuildItem.objects.create(
|
||||||
|
build=self.build,
|
||||||
|
stock_item=self.stock_2_1,
|
||||||
|
quantity=q21,
|
||||||
|
install_into=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attempt to create another identical BuildItem
|
||||||
|
b = BuildItem(
|
||||||
build=self.build,
|
build=self.build,
|
||||||
stock_item=self.stock_2_1,
|
stock_item=self.stock_2_1,
|
||||||
quantity=q21
|
quantity=q21
|
||||||
)
|
)
|
||||||
|
|
||||||
with transaction.atomic():
|
with self.assertRaises(ValidationError):
|
||||||
with self.assertRaises(IntegrityError):
|
b.clean()
|
||||||
BuildItem.objects.create(
|
|
||||||
build=self.build,
|
|
||||||
stock_item=self.stock_2_1,
|
|
||||||
quantity=99
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(BuildItem.objects.count(), 3)
|
|
||||||
|
|
||||||
def test_partial_allocation(self):
|
def test_partial_allocation(self):
|
||||||
|
"""
|
||||||
|
Partially allocate against output 1
|
||||||
|
"""
|
||||||
|
|
||||||
self.allocate_stock(50, 50, 200)
|
self.allocate_stock(50, 50, 200, self.output_1)
|
||||||
|
|
||||||
self.assertFalse(self.build.isFullyAllocated())
|
self.assertTrue(self.build.isFullyAllocated(self.output_1))
|
||||||
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1))
|
self.assertFalse(self.build.isFullyAllocated(self.output_2))
|
||||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2))
|
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
|
||||||
|
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
|
||||||
|
|
||||||
self.build.unallocateStock()
|
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_2))
|
||||||
|
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
|
||||||
|
|
||||||
|
# Check that the part has been allocated
|
||||||
|
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 100)
|
||||||
|
|
||||||
|
self.build.unallocateStock(output=self.output_1)
|
||||||
self.assertEqual(BuildItem.objects.count(), 0)
|
self.assertEqual(BuildItem.objects.count(), 0)
|
||||||
|
|
||||||
def test_auto_allocate(self):
|
# Check that the part has been unallocated
|
||||||
|
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 0)
|
||||||
|
|
||||||
allocations = self.build.getAutoAllocations()
|
def test_auto_allocate(self):
|
||||||
|
"""
|
||||||
|
Test auto-allocation functionality against the build outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
allocations = self.build.getAutoAllocations(self.output_1)
|
||||||
|
|
||||||
self.assertEqual(len(allocations), 1)
|
self.assertEqual(len(allocations), 1)
|
||||||
|
|
||||||
self.build.autoAllocate()
|
self.build.autoAllocate(self.output_1)
|
||||||
self.assertEqual(BuildItem.objects.count(), 1)
|
self.assertEqual(BuildItem.objects.count(), 1)
|
||||||
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2))
|
|
||||||
|
# Check that one part has been fully allocated to the build output
|
||||||
|
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
|
||||||
|
|
||||||
|
# But, the *other* build output has not been allocated against
|
||||||
|
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
|
||||||
|
|
||||||
def test_cancel(self):
|
def test_cancel(self):
|
||||||
|
"""
|
||||||
|
Test cancellation of the build
|
||||||
|
"""
|
||||||
|
|
||||||
self.allocate_stock(50, 50, 200)
|
# TODO
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.allocate_stock(50, 50, 200, self.output_1)
|
||||||
self.build.cancelBuild(None)
|
self.build.cancelBuild(None)
|
||||||
|
|
||||||
self.assertEqual(BuildItem.objects.count(), 0)
|
self.assertEqual(BuildItem.objects.count(), 0)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
def test_complete(self):
|
def test_complete(self):
|
||||||
|
"""
|
||||||
|
Test completion of a build output
|
||||||
|
"""
|
||||||
|
|
||||||
self.allocate_stock(50, 50, 250)
|
self.allocate_stock(50, 50, 250, self.output_1)
|
||||||
|
self.allocate_stock(50, 50, 250, self.output_2)
|
||||||
|
|
||||||
self.assertTrue(self.build.isFullyAllocated())
|
self.assertTrue(self.build.isFullyAllocated(self.output_1))
|
||||||
|
self.assertTrue(self.build.isFullyAllocated(self.output_2))
|
||||||
|
|
||||||
# Generate some serial numbers!
|
self.build.completeBuildOutput(self.output_1, None)
|
||||||
serials = ExtractSerialNumbers("1-10", 10)
|
|
||||||
|
|
||||||
self.build.completeBuild(None, serials, None)
|
self.assertFalse(self.build.can_complete)
|
||||||
|
|
||||||
|
self.build.completeBuildOutput(self.output_2, None)
|
||||||
|
|
||||||
|
self.assertTrue(self.build.can_complete)
|
||||||
|
|
||||||
|
self.build.complete_build(None)
|
||||||
|
|
||||||
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
|
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
|
||||||
|
|
||||||
@ -199,29 +265,24 @@ class BuildTest(TestCase):
|
|||||||
self.assertEqual(BuildItem.objects.count(), 0)
|
self.assertEqual(BuildItem.objects.count(), 0)
|
||||||
|
|
||||||
# New stock items should have been created!
|
# New stock items should have been created!
|
||||||
# - Ten for the build output (as the part was serialized)
|
self.assertEqual(StockItem.objects.count(), 4)
|
||||||
# - Three for the split items assigned to the build
|
|
||||||
self.assertEqual(StockItem.objects.count(), 16)
|
|
||||||
|
|
||||||
A = StockItem.objects.get(pk=self.stock_1_1.pk)
|
A = StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||||
B = StockItem.objects.get(pk=self.stock_1_2.pk)
|
|
||||||
|
# This stock item has been depleted!
|
||||||
|
with self.assertRaises(StockItem.DoesNotExist):
|
||||||
|
StockItem.objects.get(pk=self.stock_1_2.pk)
|
||||||
|
|
||||||
C = StockItem.objects.get(pk=self.stock_2_1.pk)
|
C = StockItem.objects.get(pk=self.stock_2_1.pk)
|
||||||
|
|
||||||
# Stock should have been subtracted from the original items
|
# Stock should have been subtracted from the original items
|
||||||
self.assertEqual(A.quantity, 950)
|
self.assertEqual(A.quantity, 900)
|
||||||
self.assertEqual(B.quantity, 50)
|
self.assertEqual(C.quantity, 4500)
|
||||||
self.assertEqual(C.quantity, 4750)
|
|
||||||
|
|
||||||
# New stock items should have also been allocated to the build
|
|
||||||
allocated = StockItem.objects.filter(build_order=self.build)
|
|
||||||
|
|
||||||
self.assertEqual(allocated.count(), 3)
|
|
||||||
|
|
||||||
q = sum([item.quantity for item in allocated.all()])
|
|
||||||
|
|
||||||
self.assertEqual(q, 350)
|
|
||||||
|
|
||||||
# And 10 new stock items created for the build output
|
# And 10 new stock items created for the build output
|
||||||
outputs = StockItem.objects.filter(build=self.build)
|
outputs = StockItem.objects.filter(build=self.build)
|
||||||
|
|
||||||
self.assertEqual(outputs.count(), 10)
|
self.assertEqual(outputs.count(), 2)
|
||||||
|
|
||||||
|
for output in outputs:
|
||||||
|
self.assertFalse(output.is_building)
|
||||||
|
@ -12,6 +12,7 @@ from rest_framework import status
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from .models import Build
|
from .models import Build
|
||||||
|
from stock.models import StockItem
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
|
|
||||||
@ -49,7 +50,7 @@ class BuildTestSimple(TestCase):
|
|||||||
|
|
||||||
def test_build_objects(self):
|
def test_build_objects(self):
|
||||||
# Ensure the Build objects were correctly created
|
# Ensure the Build objects were correctly created
|
||||||
self.assertEqual(Build.objects.count(), 2)
|
self.assertEqual(Build.objects.count(), 5)
|
||||||
b = Build.objects.get(pk=2)
|
b = Build.objects.get(pk=2)
|
||||||
self.assertEqual(b.batch, 'B2')
|
self.assertEqual(b.batch, 'B2')
|
||||||
self.assertEqual(b.quantity, 21)
|
self.assertEqual(b.quantity, 21)
|
||||||
@ -127,11 +128,37 @@ class TestBuildAPI(APITestCase):
|
|||||||
self.client.login(username='testuser', password='password')
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
def test_get_build_list(self):
|
def test_get_build_list(self):
|
||||||
""" Test that we can retrieve list of build objects """
|
"""
|
||||||
|
Test that we can retrieve list of build objects
|
||||||
|
"""
|
||||||
|
|
||||||
url = reverse('api-build-list')
|
url = reverse('api-build-list')
|
||||||
response = self.client.get(url, format='json')
|
response = self.client.get(url, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 5)
|
||||||
|
|
||||||
|
# Filter query by build status
|
||||||
|
response = self.client.get(url, {'status': 40}, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 4)
|
||||||
|
|
||||||
|
# Filter by "active" status
|
||||||
|
response = self.client.get(url, {'active': True}, format='json')
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]['pk'], 1)
|
||||||
|
|
||||||
|
response = self.client.get(url, {'active': False}, format='json')
|
||||||
|
self.assertEqual(len(response.data), 4)
|
||||||
|
|
||||||
|
# Filter by 'part' status
|
||||||
|
response = self.client.get(url, {'part': 25}, format='json')
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
|
# Filter by an invalid part
|
||||||
|
response = self.client.get(url, {'part': 99999}, format='json')
|
||||||
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
def test_get_build_item_list(self):
|
def test_get_build_item_list(self):
|
||||||
""" Test that we can retrieve list of BuildItem objects """
|
""" Test that we can retrieve list of BuildItem objects """
|
||||||
url = reverse('api-build-item-list')
|
url = reverse('api-build-item-list')
|
||||||
@ -176,6 +203,16 @@ class TestBuildViews(TestCase):
|
|||||||
|
|
||||||
self.client.login(username='username', password='password')
|
self.client.login(username='username', password='password')
|
||||||
|
|
||||||
|
# Create a build output for build # 1
|
||||||
|
self.build = Build.objects.get(pk=1)
|
||||||
|
|
||||||
|
self.output = StockItem.objects.create(
|
||||||
|
part=self.build.part,
|
||||||
|
quantity=self.build.quantity,
|
||||||
|
build=self.build,
|
||||||
|
is_building=True,
|
||||||
|
)
|
||||||
|
|
||||||
def test_build_index(self):
|
def test_build_index(self):
|
||||||
""" test build index view """
|
""" test build index view """
|
||||||
|
|
||||||
@ -254,10 +291,15 @@ class TestBuildViews(TestCase):
|
|||||||
# url = reverse('build-item-edit')
|
# url = reverse('build-item-edit')
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def test_build_complete(self):
|
def test_build_output_complete(self):
|
||||||
""" Test the build completion form """
|
"""
|
||||||
|
Test the build output completion form
|
||||||
|
"""
|
||||||
|
|
||||||
url = reverse('build-complete', args=(1,))
|
# Firstly, check that the build cannot be completed!
|
||||||
|
self.assertFalse(self.build.can_complete)
|
||||||
|
|
||||||
|
url = reverse('build-output-complete', args=(1,))
|
||||||
|
|
||||||
# Test without confirmation
|
# Test without confirmation
|
||||||
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
@ -267,12 +309,26 @@ class TestBuildViews(TestCase):
|
|||||||
self.assertFalse(data['form_valid'])
|
self.assertFalse(data['form_valid'])
|
||||||
|
|
||||||
# Test with confirmation, valid location
|
# Test with confirmation, valid location
|
||||||
response = self.client.post(url, {'confirm': 1, 'location': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'confirm': 1,
|
||||||
|
'confirm_incomplete': 1,
|
||||||
|
'location': 1,
|
||||||
|
'output': self.output.pk,
|
||||||
|
},
|
||||||
|
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
data = json.loads(response.content)
|
data = json.loads(response.content)
|
||||||
self.assertTrue(data['form_valid'])
|
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
|
# Test with confirmation, invalid location
|
||||||
response = self.client.post(url, {'confirm': 1, 'location': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.post(url, {'confirm': 1, 'location': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -11,12 +11,16 @@ build_detail_urls = [
|
|||||||
url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'),
|
url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'),
|
||||||
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
||||||
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
||||||
url(r'^complete/?', views.BuildComplete.as_view(), name='build-complete'),
|
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'^auto-allocate/?', views.BuildAutoAllocate.as_view(), name='build-auto-allocate'),
|
url(r'^auto-allocate/?', views.BuildAutoAllocate.as_view(), name='build-auto-allocate'),
|
||||||
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
|
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
|
||||||
|
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
|
||||||
|
|
||||||
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
|
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
|
||||||
|
|
||||||
|
url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'),
|
||||||
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),
|
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),
|
||||||
|
|
||||||
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||||
@ -31,6 +35,12 @@ build_urls = [
|
|||||||
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
|
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
url('^attachment/', include([
|
||||||
|
url('^new/', views.BuildAttachmentCreate.as_view(), name='build-attachment-create'),
|
||||||
|
url(r'^(?P<pk>\d+)/edit/', views.BuildAttachmentEdit.as_view(), name='build-attachment-edit'),
|
||||||
|
url(r'^(?P<pk>\d+)/delete/', views.BuildAttachmentDelete.as_view(), name='build-attachment-delete'),
|
||||||
|
])),
|
||||||
|
|
||||||
url(r'new/', views.BuildCreate.as_view(), name='build-create'),
|
url(r'new/', views.BuildCreate.as_view(), name='build-create'),
|
||||||
|
|
||||||
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
|
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -29,6 +29,12 @@ timezone: UTC
|
|||||||
# Set debug to False to run in production mode
|
# Set debug to False to run in production mode
|
||||||
debug: True
|
debug: True
|
||||||
|
|
||||||
|
# Set debug_toolbar to True to enable a debugging toolbar for InvenTree
|
||||||
|
# Note: This will only be displayed if DEBUG mode is enabled,
|
||||||
|
# and only if InvenTree is accessed from a local IP (127.0.0.1)
|
||||||
|
debug_toolbar: False
|
||||||
|
|
||||||
|
|
||||||
# Allowed hosts (see ALLOWED_HOSTS in Django settings documentation)
|
# Allowed hosts (see ALLOWED_HOSTS in Django settings documentation)
|
||||||
# A list of strings representing the host/domain names that this Django site can serve.
|
# A list of strings representing the host/domain names that this Django site can serve.
|
||||||
# Default behaviour is to allow all hosts (THIS IS NOT SECURE!)
|
# Default behaviour is to allow all hosts (THIS IS NOT SECURE!)
|
||||||
@ -63,11 +69,6 @@ static_root: '../inventree_static'
|
|||||||
# - git
|
# - git
|
||||||
# - ssh
|
# - ssh
|
||||||
|
|
||||||
# Set debug_toolbar to True to enable a debugging toolbar for InvenTree
|
|
||||||
# Note: This will only be displayed if DEBUG mode is enabled,
|
|
||||||
# and only if InvenTree is accessed from a local IP (127.0.0.1)
|
|
||||||
debug_toolbar: False
|
|
||||||
|
|
||||||
# Backup options
|
# Backup options
|
||||||
# Set the backup_dir parameter to store backup files in a specific location
|
# Set the backup_dir parameter to store backup files in a specific location
|
||||||
# If unspecified, the local user's temp directory will be used
|
# If unspecified, the local user's temp directory will be used
|
||||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -91,7 +91,7 @@ class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
|
|
||||||
class PurchaseOrderAttachmentCreate(AjaxCreateView):
|
class PurchaseOrderAttachmentCreate(AjaxCreateView):
|
||||||
"""
|
"""
|
||||||
View for creating a new PurchaseOrderAtt
|
View for creating a new PurchaseOrderAttachment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = PurchaseOrderAttachment
|
model = PurchaseOrderAttachment
|
||||||
@ -100,7 +100,9 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
|
|||||||
ajax_template_name = "modal_form.html"
|
ajax_template_name = "modal_form.html"
|
||||||
role_required = 'purchase_order.add'
|
role_required = 'purchase_order.add'
|
||||||
|
|
||||||
def post_save(self, attachment, form, **kwargs):
|
def save(self, form, **kwargs):
|
||||||
|
|
||||||
|
attachment = form.save(commit=False)
|
||||||
attachment.user = self.request.user
|
attachment.user = self.request.user
|
||||||
attachment.save()
|
attachment.save()
|
||||||
|
|
||||||
@ -148,9 +150,14 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
|
|||||||
ajax_form_title = _('Add Sales Order Attachment')
|
ajax_form_title = _('Add Sales Order Attachment')
|
||||||
role_required = 'sales_order.add'
|
role_required = 'sales_order.add'
|
||||||
|
|
||||||
def post_save(self, attachment, form, **kwargs):
|
def save(self, form, **kwargs):
|
||||||
self.object.user = self.request.user
|
"""
|
||||||
self.object.save()
|
Save the user that uploaded the attachment
|
||||||
|
"""
|
||||||
|
|
||||||
|
attachment = form.save(commit=False)
|
||||||
|
attachment.user = self.request.user
|
||||||
|
attachment.save()
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -295,7 +302,9 @@ class SalesOrderNotes(InvenTreeRoleMixin, UpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderCreate(AjaxCreateView):
|
class PurchaseOrderCreate(AjaxCreateView):
|
||||||
""" View for creating a new PurchaseOrder object using a modal form """
|
"""
|
||||||
|
View for creating a new PurchaseOrder object using a modal form
|
||||||
|
"""
|
||||||
|
|
||||||
model = PurchaseOrder
|
model = PurchaseOrder
|
||||||
ajax_form_title = _("Create Purchase Order")
|
ajax_form_title = _("Create Purchase Order")
|
||||||
@ -319,9 +328,12 @@ class PurchaseOrderCreate(AjaxCreateView):
|
|||||||
|
|
||||||
return initials
|
return initials
|
||||||
|
|
||||||
def post_save(self, order, form, **kwargs):
|
def save(self, form, **kwargs):
|
||||||
# Record the user who created this purchase order
|
"""
|
||||||
|
Record the user who created this PurchaseOrder
|
||||||
|
"""
|
||||||
|
|
||||||
|
order = form.save(commit=False)
|
||||||
order.created_by = self.request.user
|
order.created_by = self.request.user
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
@ -351,8 +363,12 @@ class SalesOrderCreate(AjaxCreateView):
|
|||||||
|
|
||||||
return initials
|
return initials
|
||||||
|
|
||||||
def post_save(self, order, form, **kwargs):
|
def save(self, form, **kwargs):
|
||||||
# Record the user who created this sales order
|
"""
|
||||||
|
Record the user who created this SalesOrder
|
||||||
|
"""
|
||||||
|
|
||||||
|
order = form.save(commit=False)
|
||||||
order.created_by = self.request.user
|
order.created_by = self.request.user
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
@ -414,7 +430,10 @@ class PurchaseOrderCancel(AjaxUpdateView):
|
|||||||
if not order.can_cancel():
|
if not order.can_cancel():
|
||||||
form.add_error(None, _('Order cannot be cancelled'))
|
form.add_error(None, _('Order cannot be cancelled'))
|
||||||
|
|
||||||
def post_save(self, order, form, **kwargs):
|
def save(self, order, form, **kwargs):
|
||||||
|
"""
|
||||||
|
Cancel the PurchaseOrder
|
||||||
|
"""
|
||||||
|
|
||||||
order.cancel_order()
|
order.cancel_order()
|
||||||
|
|
||||||
@ -438,7 +457,10 @@ class SalesOrderCancel(AjaxUpdateView):
|
|||||||
if not order.can_cancel():
|
if not order.can_cancel():
|
||||||
form.add_error(None, _('Order cannot be cancelled'))
|
form.add_error(None, _('Order cannot be cancelled'))
|
||||||
|
|
||||||
def post_save(self, order, form, **kwargs):
|
def save(self, order, form, **kwargs):
|
||||||
|
"""
|
||||||
|
Once the form has been validated, cancel the SalesOrder
|
||||||
|
"""
|
||||||
|
|
||||||
order.cancel_order()
|
order.cancel_order()
|
||||||
|
|
||||||
@ -459,8 +481,10 @@ class PurchaseOrderIssue(AjaxUpdateView):
|
|||||||
if not confirm:
|
if not confirm:
|
||||||
form.add_error('confirm', _('Confirm order placement'))
|
form.add_error('confirm', _('Confirm order placement'))
|
||||||
|
|
||||||
def post_save(self, order, form, **kwargs):
|
def save(self, order, form, **kwargs):
|
||||||
|
"""
|
||||||
|
Once the form has been validated, place the order.
|
||||||
|
"""
|
||||||
order.place_order()
|
order.place_order()
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
@ -495,7 +519,10 @@ class PurchaseOrderComplete(AjaxUpdateView):
|
|||||||
if not confirm:
|
if not confirm:
|
||||||
form.add_error('confirm', _('Confirm order completion'))
|
form.add_error('confirm', _('Confirm order completion'))
|
||||||
|
|
||||||
def post_save(self, order, form, **kwargs):
|
def save(self, order, form, **kwargs):
|
||||||
|
"""
|
||||||
|
Complete the PurchaseOrder
|
||||||
|
"""
|
||||||
|
|
||||||
order.complete_order()
|
order.complete_order()
|
||||||
|
|
||||||
@ -883,9 +910,10 @@ class OrderParts(AjaxView):
|
|||||||
try:
|
try:
|
||||||
build = Build.objects.get(id=build_id)
|
build = Build.objects.get(id=build_id)
|
||||||
|
|
||||||
parts = build.part.required_parts()
|
parts = build.required_parts
|
||||||
|
|
||||||
for part in parts:
|
for part in parts:
|
||||||
|
|
||||||
# If ordering from a Build page, ignore parts that we have enough of
|
# If ordering from a Build page, ignore parts that we have enough of
|
||||||
if part.quantity_to_order <= 0:
|
if part.quantity_to_order <= 0:
|
||||||
continue
|
continue
|
||||||
|
@ -9,7 +9,7 @@ from import_export.fields import Field
|
|||||||
import import_export.widgets as widgets
|
import import_export.widgets as widgets
|
||||||
|
|
||||||
from .models import PartCategory, Part
|
from .models import PartCategory, Part
|
||||||
from .models import PartAttachment, PartStar
|
from .models import PartAttachment, PartStar, PartRelated
|
||||||
from .models import BomItem
|
from .models import BomItem
|
||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate, PartParameter
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
@ -122,6 +122,11 @@ class PartCategoryAdmin(ImportExportModelAdmin):
|
|||||||
search_fields = ('name', 'description')
|
search_fields = ('name', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
class PartRelatedAdmin(admin.ModelAdmin):
|
||||||
|
''' Class to manage PartRelated objects '''
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentAdmin(admin.ModelAdmin):
|
class PartAttachmentAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
list_display = ('part', 'attachment', 'comment')
|
list_display = ('part', 'attachment', 'comment')
|
||||||
@ -292,6 +297,7 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
admin.site.register(Part, PartAdmin)
|
admin.site.register(Part, PartAdmin)
|
||||||
admin.site.register(PartCategory, PartCategoryAdmin)
|
admin.site.register(PartCategory, PartCategoryAdmin)
|
||||||
|
admin.site.register(PartRelated, PartRelatedAdmin)
|
||||||
admin.site.register(PartAttachment, PartAttachmentAdmin)
|
admin.site.register(PartAttachment, PartAttachmentAdmin)
|
||||||
admin.site.register(PartStar, PartStarAdmin)
|
admin.site.register(PartStar, PartStarAdmin)
|
||||||
admin.site.register(BomItem, BomItemAdmin)
|
admin.site.register(BomItem, BomItemAdmin)
|
||||||
|
@ -16,8 +16,15 @@ class PartConfig(AppConfig):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
self.generate_part_thumbnails()
|
self.generate_part_thumbnails()
|
||||||
|
self.update_trackable_status()
|
||||||
|
|
||||||
def generate_part_thumbnails(self):
|
def generate_part_thumbnails(self):
|
||||||
|
"""
|
||||||
|
Generate thumbnail images for any Part that does not have one.
|
||||||
|
This function exists mainly for legacy support,
|
||||||
|
as any *new* image uploaded will have a thumbnail generated automatically.
|
||||||
|
"""
|
||||||
|
|
||||||
from .models import Part
|
from .models import Part
|
||||||
|
|
||||||
print("InvenTree: Checking Part image thumbnails")
|
print("InvenTree: Checking Part image thumbnails")
|
||||||
@ -37,4 +44,27 @@ class PartConfig(AppConfig):
|
|||||||
part.image = None
|
part.image = None
|
||||||
part.save()
|
part.save()
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
|
# Exception if the database has not been migrated yet
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_trackable_status(self):
|
||||||
|
"""
|
||||||
|
Check for any instances where a trackable part is used in the BOM
|
||||||
|
for a non-trackable part.
|
||||||
|
|
||||||
|
In such a case, force the top-level part to be trackable too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import BomItem
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
print(f"Marking part '{item.part.name}' as trackable")
|
||||||
|
item.part.trackable = True
|
||||||
|
item.part.clean()
|
||||||
|
item.part.save()
|
||||||
|
except (OperationalError, ProgrammingError):
|
||||||
|
# Exception if the database has not been migrated yet
|
||||||
pass
|
pass
|
||||||
|
@ -67,6 +67,7 @@
|
|||||||
name: 'Widget'
|
name: 'Widget'
|
||||||
description: 'A watchamacallit'
|
description: 'A watchamacallit'
|
||||||
category: 7
|
category: 7
|
||||||
|
assembly: true
|
||||||
trackable: true
|
trackable: true
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
level: 0
|
level: 0
|
||||||
|
@ -13,7 +13,7 @@ from mptt.fields import TreeNodeChoiceField
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from .models import Part, PartCategory, PartAttachment
|
from .models import Part, PartCategory, PartAttachment, PartRelated
|
||||||
from .models import BomItem
|
from .models import BomItem
|
||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate, PartParameter
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
@ -142,6 +142,25 @@ class BomUploadSelectFile(HelperForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePartRelatedForm(HelperForm):
|
||||||
|
""" Form for creating a PartRelated object """
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PartRelated
|
||||||
|
fields = [
|
||||||
|
'part_1',
|
||||||
|
'part_2',
|
||||||
|
]
|
||||||
|
labels = {
|
||||||
|
'part_2': _('Related Part'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
""" Disable model saving """
|
||||||
|
|
||||||
|
return super(CreatePartRelatedForm, self).save(commit=False)
|
||||||
|
|
||||||
|
|
||||||
class EditPartAttachmentForm(HelperForm):
|
class EditPartAttachmentForm(HelperForm):
|
||||||
""" Form for editing a PartAttachment object """
|
""" Form for editing a PartAttachment object """
|
||||||
|
|
||||||
|
22
InvenTree/part/migrations/0052_partrelated.py
Normal file
22
InvenTree/part/migrations/0052_partrelated.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2020-10-16 20:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0051_bomitem_optional'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PartRelated',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('part_1', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_1', to='part.Part')),
|
||||||
|
('part_2', models.ForeignKey(help_text='Select Related Part', on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_2', to='part.Part')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
14
InvenTree/part/migrations/0053_merge_20201103_1028.py
Normal file
14
InvenTree/part/migrations/0053_merge_20201103_1028.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2020-11-03 10:28
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0052_auto_20201027_1557'),
|
||||||
|
('part', '0052_partrelated'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
]
|
@ -564,10 +564,24 @@ class Part(MPTTModel):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
""" Perform cleaning operations for the Part model """
|
"""
|
||||||
|
Perform cleaning operations for the Part model
|
||||||
|
|
||||||
|
Update trackable status:
|
||||||
|
If this part is trackable, and it is used in the BOM
|
||||||
|
for a parent part which is *not* trackable,
|
||||||
|
then we will force the parent part to be trackable.
|
||||||
|
"""
|
||||||
|
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
|
if self.trackable:
|
||||||
|
for parent_part in self.used_in.all():
|
||||||
|
if not parent_part.trackable:
|
||||||
|
parent_part.trackable = True
|
||||||
|
parent_part.clean()
|
||||||
|
parent_part.save()
|
||||||
|
|
||||||
name = models.CharField(max_length=100, blank=False,
|
name = models.CharField(max_length=100, blank=False,
|
||||||
help_text=_('Part name'),
|
help_text=_('Part name'),
|
||||||
validators=[validators.validate_part_name]
|
validators=[validators.validate_part_name]
|
||||||
@ -909,6 +923,19 @@ class Part(MPTTModel):
|
|||||||
def has_bom(self):
|
def has_bom(self):
|
||||||
return self.bom_count > 0
|
return self.bom_count > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_trackable_parts(self):
|
||||||
|
"""
|
||||||
|
Return True if any parts linked in the Bill of Materials are trackable.
|
||||||
|
This is important when building the part.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for bom_item in self.bom_items.all():
|
||||||
|
if bom_item.sub_part.trackable:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bom_count(self):
|
def bom_count(self):
|
||||||
""" Return the number of items contained in the BOM for this part """
|
""" Return the number of items contained in the BOM for this part """
|
||||||
@ -966,15 +993,31 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
self.bom_items.all().delete()
|
self.bom_items.all().delete()
|
||||||
|
|
||||||
def required_parts(self):
|
def getRequiredParts(self, recursive=False, parts=set()):
|
||||||
""" Return a list of parts required to make this part (list of BOM items) """
|
"""
|
||||||
parts = []
|
Return a list of parts required to make this part (i.e. BOM items).
|
||||||
for bom in self.bom_items.all().select_related('sub_part'):
|
|
||||||
parts.append(bom.sub_part)
|
Args:
|
||||||
|
recursive: If True iterate down through sub-assemblies
|
||||||
|
parts: Set of parts already found (to prevent recursion issues)
|
||||||
|
"""
|
||||||
|
|
||||||
|
for bom_item in self.bom_items.all().select_related('sub_part'):
|
||||||
|
|
||||||
|
sub_part = bom_item.sub_part
|
||||||
|
|
||||||
|
if sub_part not in parts:
|
||||||
|
|
||||||
|
parts.add(sub_part)
|
||||||
|
|
||||||
|
if recursive:
|
||||||
|
sub_part.getRequiredParts(recursive=True, parts=parts)
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
def get_allowed_bom_items(self):
|
def get_allowed_bom_items(self):
|
||||||
""" Return a list of parts which can be added to a BOM for this part.
|
"""
|
||||||
|
Return a list of parts which can be added to a BOM for this part.
|
||||||
|
|
||||||
- Exclude parts which are not 'component' parts
|
- Exclude parts which are not 'component' parts
|
||||||
- Exclude parts which this part is in the BOM for
|
- Exclude parts which this part is in the BOM for
|
||||||
@ -1176,7 +1219,7 @@ class Part(MPTTModel):
|
|||||||
parameter.save()
|
parameter.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def deepCopy(self, other, **kwargs):
|
def deep_copy(self, other, **kwargs):
|
||||||
""" Duplicates non-field data from another part.
|
""" Duplicates non-field data from another part.
|
||||||
Does not alter the normal fields of this part,
|
Does not alter the normal fields of this part,
|
||||||
but can be used to copy other data linked by ForeignKey refernce.
|
but can be used to copy other data linked by ForeignKey refernce.
|
||||||
@ -1331,6 +1374,32 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return self.get_descendants(include_self=False)
|
return self.get_descendants(include_self=False)
|
||||||
|
|
||||||
|
def get_related_parts(self):
|
||||||
|
""" Return list of tuples for all related parts:
|
||||||
|
- first value is PartRelated object
|
||||||
|
- second value is matching Part object
|
||||||
|
"""
|
||||||
|
|
||||||
|
related_parts = []
|
||||||
|
|
||||||
|
related_parts_1 = self.related_parts_1.filter(part_1__id=self.pk)
|
||||||
|
|
||||||
|
related_parts_2 = self.related_parts_2.filter(part_2__id=self.pk)
|
||||||
|
|
||||||
|
for related_part in related_parts_1:
|
||||||
|
# Add to related parts list
|
||||||
|
related_parts.append((related_part, related_part.part_2))
|
||||||
|
|
||||||
|
for related_part in related_parts_2:
|
||||||
|
# Add to related parts list
|
||||||
|
related_parts.append((related_part, related_part.part_1))
|
||||||
|
|
||||||
|
return related_parts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def related_count(self):
|
||||||
|
return len(self.get_related_parts())
|
||||||
|
|
||||||
|
|
||||||
def attach_file(instance, filename):
|
def attach_file(instance, filename):
|
||||||
""" Function for storing a file for a PartAttachment
|
""" Function for storing a file for a PartAttachment
|
||||||
@ -1715,12 +1784,15 @@ class BomItem(models.Model):
|
|||||||
return self.get_item_hash() == self.checksum
|
return self.get_item_hash() == self.checksum
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
""" Check validity of the BomItem model.
|
"""
|
||||||
|
Check validity of the BomItem model.
|
||||||
|
|
||||||
Performs model checks beyond simple field validation.
|
Performs model checks beyond simple field validation.
|
||||||
|
|
||||||
- A part cannot refer to itself in its BOM
|
- A part cannot refer to itself in its BOM
|
||||||
- A part cannot refer to a part which refers to it
|
- A part cannot refer to a part which refers to it
|
||||||
|
|
||||||
|
- If the "sub_part" is trackable, then the "part" must be trackable too!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
|
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
|
||||||
@ -1730,6 +1802,13 @@ class BomItem(models.Model):
|
|||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"quantity": _("Quantity must be integer value for trackable parts")
|
"quantity": _("Quantity must be integer value for trackable parts")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Force the upstream part to be trackable if the sub_part is trackable
|
||||||
|
if not self.part.trackable:
|
||||||
|
self.part.trackable = True
|
||||||
|
self.part.clean()
|
||||||
|
self.part.save()
|
||||||
|
|
||||||
except Part.DoesNotExist:
|
except Part.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -1842,3 +1921,71 @@ class BomItem(models.Model):
|
|||||||
pmax = decimal2string(pmax)
|
pmax = decimal2string(pmax)
|
||||||
|
|
||||||
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
||||||
|
|
||||||
|
|
||||||
|
class PartRelated(models.Model):
|
||||||
|
""" Store and handle related parts (eg. mating connector, crimps, etc.) """
|
||||||
|
|
||||||
|
part_1 = models.ForeignKey(Part, related_name='related_parts_1',
|
||||||
|
on_delete=models.DO_NOTHING)
|
||||||
|
|
||||||
|
part_2 = models.ForeignKey(Part, related_name='related_parts_2',
|
||||||
|
on_delete=models.DO_NOTHING,
|
||||||
|
help_text=_('Select Related Part'))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.part_1} <--> {self.part_2}'
|
||||||
|
|
||||||
|
def validate(self, part_1, part_2):
|
||||||
|
''' Validate that the two parts relationship is unique '''
|
||||||
|
|
||||||
|
validate = True
|
||||||
|
|
||||||
|
parts = Part.objects.all()
|
||||||
|
related_parts = PartRelated.objects.all()
|
||||||
|
|
||||||
|
# Check if part exist and there are not the same part
|
||||||
|
if (part_1 in parts and part_2 in parts) and (part_1.pk != part_2.pk):
|
||||||
|
# Check if relation exists already
|
||||||
|
for relation in related_parts:
|
||||||
|
if (part_1 == relation.part_1 and part_2 == relation.part_2) \
|
||||||
|
or (part_1 == relation.part_2 and part_2 == relation.part_1):
|
||||||
|
validate = False
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
validate = False
|
||||||
|
|
||||||
|
return validate
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
''' Overwrite clean method to check that relation is unique '''
|
||||||
|
|
||||||
|
validate = self.validate(self.part_1, self.part_2)
|
||||||
|
|
||||||
|
if not validate:
|
||||||
|
error_message = _('Error creating relationship: check that '
|
||||||
|
'the part is not related to itself '
|
||||||
|
'and that the relationship is unique')
|
||||||
|
|
||||||
|
raise ValidationError(error_message)
|
||||||
|
|
||||||
|
def create_relationship(self, part_1, part_2):
|
||||||
|
''' Create relationship between two parts '''
|
||||||
|
|
||||||
|
validate = self.validate(part_1, part_2)
|
||||||
|
|
||||||
|
if validate:
|
||||||
|
# Add relationship
|
||||||
|
self.part_1 = part_1
|
||||||
|
self.part_2 = part_2
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
return validate
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, part_1, part_2):
|
||||||
|
''' Create PartRelated object and relationship between two parts '''
|
||||||
|
|
||||||
|
related_part = cls()
|
||||||
|
related_part.create_relationship(part_1, part_2)
|
||||||
|
return related_part
|
||||||
|
@ -10,13 +10,9 @@
|
|||||||
|
|
||||||
{% include 'part/tabs.html' with tab='bom' %}
|
{% include 'part/tabs.html' with tab='bom' %}
|
||||||
|
|
||||||
<h3>{% trans "Bill of Materials" %}</h3>
|
<h4>{% trans "Bill of Materials" %}</h4>
|
||||||
|
<hr>
|
||||||
|
|
||||||
{% if part.has_complete_bom_pricing == False %}
|
|
||||||
<div class='alert alert-block alert-warning'>
|
|
||||||
The BOM for <i>{{ part.full_name }}</i> does not have complete pricing information
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if part.bom_checked_date %}
|
{% if part.bom_checked_date %}
|
||||||
{% if part.is_bom_valid %}
|
{% if part.is_bom_valid %}
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
|
@ -5,13 +5,14 @@
|
|||||||
|
|
||||||
{% include 'part/tabs.html' with tab='build' %}
|
{% include 'part/tabs.html' with tab='build' %}
|
||||||
|
|
||||||
<h3>{% trans "Part Builds" %}</h3>
|
<h4>{% trans "Part Builds" %}</h4>
|
||||||
|
<hr>
|
||||||
|
|
||||||
<div id='button-toolbar'>
|
<div id='button-toolbar'>
|
||||||
<div class='button-toolbar container-flui' style='float: right';>
|
<div class='button-toolbar container-flui' style='float: right';>
|
||||||
{% if part.active %}
|
{% if part.active %}
|
||||||
{% if roles.build.add %}
|
{% if roles.build.add %}
|
||||||
<button class="btn btn-success" id='start-build'>{% trans "Start New Build" %}</button>
|
<button class="btn btn-success" id='start-build'><span class='fas fa-tools'></span> {% trans "Start New Build" %}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='filter-list' id='filter-list-build'>
|
<div class='filter-list' id='filter-list-build'>
|
||||||
@ -29,12 +30,9 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
$("#start-build").click(function() {
|
$("#start-build").click(function() {
|
||||||
launchModalForm(
|
newBuildOrder({
|
||||||
"{% url 'build-create' %}",
|
|
||||||
{
|
|
||||||
follow: true,
|
|
||||||
data: {
|
data: {
|
||||||
part: {{ part.id }}
|
part: {{ part.id }},
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -41,7 +41,7 @@
|
|||||||
{% if part.getLatestSerialNumber %}
|
{% if part.getLatestSerialNumber %}
|
||||||
{{ part.getLatestSerialNumber }}
|
{{ part.getLatestSerialNumber }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "No serial numbers recorded" %}
|
<i>{% trans "No serial numbers recorded" %}</i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
77
InvenTree/part/templates/part/related.html
Normal file
77
InvenTree/part/templates/part/related.html
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
{% extends "part/part_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block details %}
|
||||||
|
|
||||||
|
{% include 'part/tabs.html' with tab='related-parts' %}
|
||||||
|
|
||||||
|
<h4>{% trans "Related Parts" %}</h4>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div id='button-bar'>
|
||||||
|
<div class='button-toolbar container-fluid' style='float: left;'>
|
||||||
|
{% if roles.part.change %}
|
||||||
|
<button class='btn btn-primary' type='button' id='add-related-part' title='{% trans "Add Related" %}'>{% trans "Add Related" %}</button>
|
||||||
|
<div class='filter-list' id='filter-list-related'>
|
||||||
|
<!-- An empty div in which the filter list will be constructed -->
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id='table-related-part' class='table table-condensed table-striped' data-toolbar='#button-toolbar'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-field='part' data-serachable='true'>{% trans "Part" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in part.get_related_parts %}
|
||||||
|
{% with part_related=item.0 part=item.1 %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class='hover-icon'>
|
||||||
|
<img class='hover-img-thumb' src='{{ part.get_thumbnail_url }}'>
|
||||||
|
<img class='hover-img-large' src='{{ part.get_thumbnail_url }}'>
|
||||||
|
</a>
|
||||||
|
<a href='/part/{{ part.id }}/'>{{ part }}</a>
|
||||||
|
<div class='btn-group' style='float: right;'>
|
||||||
|
{% if roles.part.change %}
|
||||||
|
<button title='{% trans "Delete" %}' class='btn btn-default btn-glyph delete-related-part' url="{% url 'part-related-delete' part_related.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
$('#table-related-part').inventreeTable({
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#add-related-part").click(function() {
|
||||||
|
launchModalForm("{% url 'part-related-create' %}", {
|
||||||
|
data: {
|
||||||
|
part: {{ part.id }},
|
||||||
|
},
|
||||||
|
reload: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.delete-related-part').click(function() {
|
||||||
|
var button = $(this);
|
||||||
|
|
||||||
|
launchModalForm(button.attr('url'), {
|
||||||
|
reload: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -63,6 +63,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li{% ifequal tab 'related-parts' %} class="active"{% endifequal %}>
|
||||||
|
<a href="{% url 'part-related' part.id %}">{% trans "Related" %} {% if part.related_count > 0 %}<span class="badge">{{ part.related_count }}</span>{% endif %}</a>
|
||||||
|
</li>
|
||||||
<li{% ifequal tab 'attachments' %} class="active"{% endifequal %}>
|
<li{% ifequal tab 'attachments' %} class="active"{% endifequal %}>
|
||||||
<a href="{% url 'part-attachments' part.id %}">{% trans "Attachments" %} {% if part.attachment_count > 0 %}<span class="badge">{{ part.attachment_count }}</span>{% endif %}</a>
|
<a href="{% url 'part-attachments' part.id %}">{% trans "Attachments" %} {% if part.attachment_count > 0 %}<span class="badge">{{ part.attachment_count }}</span>{% endif %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -13,6 +13,19 @@ from common.models import InvenTreeSetting, ColorTheme
|
|||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def define(value, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Shortcut function to overcome the shortcomings of the django templating language
|
||||||
|
|
||||||
|
Use as follows: {% define "hello_world" as hello %}
|
||||||
|
|
||||||
|
Ref: https://stackoverflow.com/questions/1070398/how-to-set-a-value-of-a-variable-inside-a-template-code
|
||||||
|
"""
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def decimal(x, *args, **kwargs):
|
def decimal(x, *args, **kwargs):
|
||||||
""" Simplified rendering of a decimal number """
|
""" Simplified rendering of a decimal number """
|
||||||
|
@ -28,10 +28,12 @@ class BomItemTest(TestCase):
|
|||||||
self.assertEqual(self.bob.bom_count, 4)
|
self.assertEqual(self.bob.bom_count, 4)
|
||||||
|
|
||||||
def test_in_bom(self):
|
def test_in_bom(self):
|
||||||
parts = self.bob.required_parts()
|
parts = self.bob.getRequiredParts()
|
||||||
|
|
||||||
self.assertIn(self.orphan, parts)
|
self.assertIn(self.orphan, parts)
|
||||||
|
|
||||||
|
# TODO: Tests for multi-level BOMs
|
||||||
|
|
||||||
def test_used_in(self):
|
def test_used_in(self):
|
||||||
self.assertEqual(self.bob.used_in_count, 0)
|
self.assertEqual(self.bob.used_in_count, 0)
|
||||||
self.assertEqual(self.orphan.used_in_count, 1)
|
self.assertEqual(self.orphan.used_in_count, 1)
|
||||||
|
@ -99,7 +99,7 @@ class PartTest(TestCase):
|
|||||||
self.assertIn(self.R1.name, barcode)
|
self.assertIn(self.R1.name, barcode)
|
||||||
|
|
||||||
def test_copy(self):
|
def test_copy(self):
|
||||||
self.R2.deepCopy(self.R1, image=True, bom=True)
|
self.R2.deep_copy(self.R1, image=True, bom=True)
|
||||||
|
|
||||||
def test_match_names(self):
|
def test_match_names(self):
|
||||||
|
|
||||||
|
@ -201,6 +201,29 @@ class PartTests(PartViewTestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class PartRelatedTests(PartViewTestCase):
|
||||||
|
|
||||||
|
def test_valid_create(self):
|
||||||
|
""" test creation of an attachment for a valid part """
|
||||||
|
|
||||||
|
response = self.client.get(reverse('part-related-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# TODO - Create a new attachment using this view
|
||||||
|
|
||||||
|
def test_invalid_create(self):
|
||||||
|
""" test creation of an attachment for an invalid part """
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_edit(self):
|
||||||
|
""" test editing an attachment """
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentTests(PartViewTestCase):
|
class PartAttachmentTests(PartViewTestCase):
|
||||||
|
|
||||||
def test_valid_create(self):
|
def test_valid_create(self):
|
||||||
|
@ -12,6 +12,11 @@ from django.conf.urls import url, include
|
|||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
part_related_urls = [
|
||||||
|
url(r'^new/?', views.PartRelatedCreate.as_view(), name='part-related-create'),
|
||||||
|
url(r'^(?P<pk>\d+)/delete/?', views.PartRelatedDelete.as_view(), name='part-related-delete'),
|
||||||
|
]
|
||||||
|
|
||||||
part_attachment_urls = [
|
part_attachment_urls = [
|
||||||
url(r'^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'),
|
url(r'^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'),
|
||||||
url(r'^(?P<pk>\d+)/edit/?', views.PartAttachmentEdit.as_view(), name='part-attachment-edit'),
|
url(r'^(?P<pk>\d+)/edit/?', views.PartAttachmentEdit.as_view(), name='part-attachment-edit'),
|
||||||
@ -61,6 +66,7 @@ part_detail_urls = [
|
|||||||
url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
|
url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
|
||||||
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
|
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
|
||||||
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
|
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
|
||||||
|
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
|
||||||
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
|
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
|
||||||
url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
|
url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
|
||||||
|
|
||||||
@ -121,6 +127,9 @@ part_urls = [
|
|||||||
# Part category
|
# Part category
|
||||||
url(r'^category/(?P<pk>\d+)/', include(part_category_urls)),
|
url(r'^category/(?P<pk>\d+)/', include(part_category_urls)),
|
||||||
|
|
||||||
|
# Part related
|
||||||
|
url(r'^related-parts/', include(part_related_urls)),
|
||||||
|
|
||||||
# Part attachments
|
# Part attachments
|
||||||
url(r'^attachment/', include(part_attachment_urls)),
|
url(r'^attachment/', include(part_attachment_urls)),
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ import os
|
|||||||
from rapidfuzz import fuzz
|
from rapidfuzz import fuzz
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from .models import PartCategory, Part, PartAttachment
|
from .models import PartCategory, Part, PartAttachment, PartRelated
|
||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate, PartParameter
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import BomItem
|
from .models import BomItem
|
||||||
@ -72,6 +72,85 @@ class PartIndex(InvenTreeRoleMixin, ListView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class PartRelatedCreate(AjaxCreateView):
|
||||||
|
""" View for creating a new PartRelated object
|
||||||
|
|
||||||
|
- The view only makes sense if a Part object is passed to it
|
||||||
|
"""
|
||||||
|
model = PartRelated
|
||||||
|
form_class = part_forms.CreatePartRelatedForm
|
||||||
|
ajax_form_title = _("Add Related Part")
|
||||||
|
ajax_template_name = "modal_form.html"
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
""" Set parent part as part_1 field """
|
||||||
|
|
||||||
|
initials = {}
|
||||||
|
|
||||||
|
part_id = self.request.GET.get('part', None)
|
||||||
|
|
||||||
|
if part_id:
|
||||||
|
try:
|
||||||
|
initials['part_1'] = Part.objects.get(pk=part_id)
|
||||||
|
except (Part.DoesNotExist, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return initials
|
||||||
|
|
||||||
|
def get_form(self):
|
||||||
|
""" Create a form to upload a new PartRelated
|
||||||
|
|
||||||
|
- Hide the 'part_1' field (parent part)
|
||||||
|
- Display parts which are not yet related
|
||||||
|
"""
|
||||||
|
|
||||||
|
form = super(AjaxCreateView, self).get_form()
|
||||||
|
|
||||||
|
form.fields['part_1'].widget = HiddenInput()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get parent part
|
||||||
|
parent_part = self.get_initial()['part_1']
|
||||||
|
# Get existing related parts
|
||||||
|
related_parts = [related_part[1].pk for related_part in parent_part.get_related_parts()]
|
||||||
|
|
||||||
|
# Build updated choice list excluding
|
||||||
|
# - parts already related to parent part
|
||||||
|
# - the parent part itself
|
||||||
|
updated_choices = []
|
||||||
|
for choice in form.fields["part_2"].choices:
|
||||||
|
if (choice[0] not in related_parts) and (choice[0] != parent_part.pk):
|
||||||
|
updated_choices.append(choice)
|
||||||
|
|
||||||
|
# Update choices for related part
|
||||||
|
form.fields['part_2'].choices = updated_choices
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
|
def post_save(self):
|
||||||
|
""" Save PartRelated model (POST method does not) """
|
||||||
|
|
||||||
|
form = self.get_form()
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
part_1 = form.cleaned_data['part_1']
|
||||||
|
part_2 = form.cleaned_data['part_2']
|
||||||
|
|
||||||
|
PartRelated.create(part_1, part_2)
|
||||||
|
|
||||||
|
|
||||||
|
class PartRelatedDelete(AjaxDeleteView):
|
||||||
|
""" View for deleting a PartRelated object """
|
||||||
|
|
||||||
|
model = PartRelated
|
||||||
|
ajax_form_title = _("Delete Related Part")
|
||||||
|
context_object_name = "related"
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentCreate(AjaxCreateView):
|
class PartAttachmentCreate(AjaxCreateView):
|
||||||
""" View for creating a new PartAttachment object
|
""" View for creating a new PartAttachment object
|
||||||
|
|
||||||
@ -84,10 +163,14 @@ class PartAttachmentCreate(AjaxCreateView):
|
|||||||
|
|
||||||
role_required = 'part.add'
|
role_required = 'part.add'
|
||||||
|
|
||||||
def post_save(self):
|
def save(self, form, **kwargs):
|
||||||
""" Record the user that uploaded the attachment """
|
"""
|
||||||
self.object.user = self.request.user
|
Record the user that uploaded this attachment
|
||||||
self.object.save()
|
"""
|
||||||
|
|
||||||
|
attachment = form.save(commit=False)
|
||||||
|
attachment.user = self.request.user
|
||||||
|
attachment.save()
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -362,7 +445,7 @@ class MakePartVariant(AjaxCreateView):
|
|||||||
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
|
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
|
||||||
|
|
||||||
# Copy relevent information from the template part
|
# Copy relevent information from the template part
|
||||||
part.deepCopy(part_template, bom=bom_copy, parameters=parameters_copy)
|
part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy)
|
||||||
|
|
||||||
return self.renderJsonResponse(request, form, data, context=context)
|
return self.renderJsonResponse(request, form, data, context=context)
|
||||||
|
|
||||||
@ -475,7 +558,7 @@ class PartDuplicate(AjaxCreateView):
|
|||||||
original = self.get_part_to_copy()
|
original = self.get_part_to_copy()
|
||||||
|
|
||||||
if original:
|
if original:
|
||||||
part.deepCopy(original, bom=bom_copy, parameters=parameters_copy)
|
part.deep_copy(original, bom=bom_copy, parameters=parameters_copy)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data['url'] = part.get_absolute_url()
|
data['url'] = part.get_absolute_url()
|
||||||
@ -894,7 +977,10 @@ class BomDuplicate(AjaxUpdateView):
|
|||||||
if not confirm:
|
if not confirm:
|
||||||
form.add_error('confirm', _('Confirm duplication of BOM from parent'))
|
form.add_error('confirm', _('Confirm duplication of BOM from parent'))
|
||||||
|
|
||||||
def post_save(self, part, form):
|
def save(self, part, form):
|
||||||
|
"""
|
||||||
|
Duplicate BOM from the specified parent
|
||||||
|
"""
|
||||||
|
|
||||||
parent = form.cleaned_data.get('parent', None)
|
parent = form.cleaned_data.get('parent', None)
|
||||||
|
|
||||||
@ -935,7 +1021,10 @@ class BomValidate(AjaxUpdateView):
|
|||||||
if not confirm:
|
if not confirm:
|
||||||
form.add_error('validate', _('Confirm that the BOM is valid'))
|
form.add_error('validate', _('Confirm that the BOM is valid'))
|
||||||
|
|
||||||
def post_save(self, part, form, **kwargs):
|
def save(self, part, form, **kwargs):
|
||||||
|
"""
|
||||||
|
Mark the BOM as validated
|
||||||
|
"""
|
||||||
|
|
||||||
part.validate_bom(self.request.user)
|
part.validate_bom(self.request.user)
|
||||||
|
|
||||||
@ -2371,7 +2460,7 @@ class BomItemCreate(AjaxCreateView):
|
|||||||
query = query.filter(active=True)
|
query = query.filter(active=True)
|
||||||
|
|
||||||
# Eliminate any options that are already in the BOM!
|
# Eliminate any options that are already in the BOM!
|
||||||
query = query.exclude(id__in=[item.id for item in part.required_parts()])
|
query = query.exclude(id__in=[item.id for item in part.getRequiredParts()])
|
||||||
|
|
||||||
form.fields['sub_part'].queryset = query
|
form.fields['sub_part'].queryset = query
|
||||||
|
|
||||||
@ -2439,7 +2528,7 @@ class BomItemEdit(AjaxUpdateView):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
sub_part_id = -1
|
sub_part_id = -1
|
||||||
|
|
||||||
existing = [item.pk for item in part.required_parts()]
|
existing = [item.pk for item in part.getRequiredParts()]
|
||||||
|
|
||||||
if sub_part_id in existing:
|
if sub_part_id in existing:
|
||||||
existing.remove(sub_part_id)
|
existing.remove(sub_part_id)
|
||||||
|
@ -493,6 +493,12 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
if build_order:
|
if build_order:
|
||||||
queryset = queryset.filter(build_order=build_order)
|
queryset = queryset.filter(build_order=build_order)
|
||||||
|
|
||||||
|
is_building = params.get('is_building', None)
|
||||||
|
|
||||||
|
if is_building:
|
||||||
|
is_building = str2bool(is_building)
|
||||||
|
queryset = queryset.filter(is_building=is_building)
|
||||||
|
|
||||||
sales_order = params.get('sales_order', None)
|
sales_order = params.get('sales_order', None)
|
||||||
|
|
||||||
if sales_order:
|
if sales_order:
|
||||||
|
@ -41,6 +41,7 @@
|
|||||||
- model: stock.stockitem
|
- model: stock.stockitem
|
||||||
pk: 100
|
pk: 100
|
||||||
fields:
|
fields:
|
||||||
|
batch: "B1234"
|
||||||
part: 25
|
part: 25
|
||||||
location: 7
|
location: 7
|
||||||
quantity: 10
|
quantity: 10
|
||||||
@ -54,6 +55,7 @@
|
|||||||
pk: 101
|
pk: 101
|
||||||
fields:
|
fields:
|
||||||
part: 25
|
part: 25
|
||||||
|
batch: "B2345"
|
||||||
location: 7
|
location: 7
|
||||||
quantity: 5
|
quantity: 5
|
||||||
level: 0
|
level: 0
|
||||||
@ -65,6 +67,7 @@
|
|||||||
pk: 102
|
pk: 102
|
||||||
fields:
|
fields:
|
||||||
part: 25
|
part: 25
|
||||||
|
batch: 'ABCDE'
|
||||||
location: 7
|
location: 7
|
||||||
quantity: 3
|
quantity: 3
|
||||||
level: 0
|
level: 0
|
||||||
@ -91,6 +94,7 @@
|
|||||||
part: 10001
|
part: 10001
|
||||||
location: 7
|
location: 7
|
||||||
quantity: 5
|
quantity: 5
|
||||||
|
batch: "AAA"
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@ -101,6 +105,7 @@
|
|||||||
fields:
|
fields:
|
||||||
part: 10001
|
part: 10001
|
||||||
location: 7
|
location: 7
|
||||||
|
batch: "AAA"
|
||||||
quantity: 1
|
quantity: 1
|
||||||
serial: 1
|
serial: 1
|
||||||
level: 0
|
level: 0
|
||||||
|
@ -108,7 +108,7 @@ class ConvertStockItemForm(HelperForm):
|
|||||||
class CreateStockItemForm(HelperForm):
|
class CreateStockItemForm(HelperForm):
|
||||||
""" Form for creating a new StockItem """
|
""" Form for creating a new StockItem """
|
||||||
|
|
||||||
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
|
serial_numbers = forms.CharField(label=_('Serial numbers'), required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
|
@ -130,7 +130,7 @@ class StockItem(MPTTModel):
|
|||||||
status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
|
status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
|
||||||
notes: Extra notes field
|
notes: Extra notes field
|
||||||
build: Link to a Build (if this stock item was created from a build)
|
build: Link to a Build (if this stock item was created from a build)
|
||||||
is_building: Boolean field indicating if this stock item is currently being built
|
is_building: Boolean field indicating if this stock item is currently being built (or is "in production")
|
||||||
purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
|
purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
|
||||||
infinite: If True this StockItem can never be exhausted
|
infinite: If True this StockItem can never be exhausted
|
||||||
sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
|
sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
|
||||||
@ -139,6 +139,7 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
# A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
|
# A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
|
||||||
IN_STOCK_FILTER = Q(
|
IN_STOCK_FILTER = Q(
|
||||||
|
quantity__gt=0,
|
||||||
sales_order=None,
|
sales_order=None,
|
||||||
build_order=None,
|
build_order=None,
|
||||||
belongs_to=None,
|
belongs_to=None,
|
||||||
@ -174,7 +175,7 @@ class StockItem(MPTTModel):
|
|||||||
if add_note:
|
if add_note:
|
||||||
# This StockItem is being saved for the first time
|
# This StockItem is being saved for the first time
|
||||||
self.addTransactionNote(
|
self.addTransactionNote(
|
||||||
'Created stock item',
|
_('Created stock item'),
|
||||||
user,
|
user,
|
||||||
notes="Created new stock item for part '{p}'".format(p=str(self.part)),
|
notes="Created new stock item for part '{p}'".format(p=str(self.part)),
|
||||||
system=True
|
system=True
|
||||||
@ -199,6 +200,7 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
super(StockItem, self).validate_unique(exclude)
|
super(StockItem, self).validate_unique(exclude)
|
||||||
|
|
||||||
|
# If the serial number is set, make sure it is not a duplicate
|
||||||
if self.serial is not None:
|
if self.serial is not None:
|
||||||
# Query to look for duplicate serial numbers
|
# Query to look for duplicate serial numbers
|
||||||
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
|
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
|
||||||
@ -718,6 +720,15 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def in_stock(self):
|
def in_stock(self):
|
||||||
|
"""
|
||||||
|
Returns True if this item is in stock
|
||||||
|
|
||||||
|
See also: IN_STOCK_FILTER
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Quantity must be above zero (unless infinite)
|
||||||
|
if self.quantity <= 0 and not self.infinite:
|
||||||
|
return False
|
||||||
|
|
||||||
# Not 'in stock' if it has been installed inside another StockItem
|
# Not 'in stock' if it has been installed inside another StockItem
|
||||||
if self.belongs_to is not None:
|
if self.belongs_to is not None:
|
||||||
@ -1049,7 +1060,7 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
if self.updateQuantity(count):
|
if self.updateQuantity(count):
|
||||||
|
|
||||||
self.addTransactionNote('Stocktake - counted {n} items'.format(n=count),
|
self.addTransactionNote('Stocktake - counted {n} items'.format(n=helpers.normalize(count)),
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
system=True)
|
system=True)
|
||||||
@ -1078,7 +1089,7 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
if self.updateQuantity(self.quantity + quantity):
|
if self.updateQuantity(self.quantity + quantity):
|
||||||
|
|
||||||
self.addTransactionNote('Added {n} items to stock'.format(n=quantity),
|
self.addTransactionNote('Added {n} items to stock'.format(n=helpers.normalize(quantity)),
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
system=True)
|
system=True)
|
||||||
@ -1104,7 +1115,7 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
if self.updateQuantity(self.quantity - quantity):
|
if self.updateQuantity(self.quantity - quantity):
|
||||||
|
|
||||||
self.addTransactionNote('Removed {n} items from stock'.format(n=quantity),
|
self.addTransactionNote('Removed {n} items from stock'.format(n=helpers.normalize(quantity)),
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
system=True)
|
system=True)
|
||||||
|
@ -154,9 +154,11 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
'allocated',
|
'allocated',
|
||||||
'batch',
|
'batch',
|
||||||
'belongs_to',
|
'belongs_to',
|
||||||
'customer',
|
'build',
|
||||||
'build_order',
|
'build_order',
|
||||||
|
'customer',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
|
'is_building',
|
||||||
'link',
|
'link',
|
||||||
'location',
|
'location',
|
||||||
'location_detail',
|
'location_detail',
|
||||||
|
@ -15,6 +15,20 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
{% block pre_content %}
|
{% block pre_content %}
|
||||||
{% include 'stock/loc_link.html' with location=item.location %}
|
{% include 'stock/loc_link.html' with location=item.location %}
|
||||||
|
|
||||||
|
{% if item.is_building %}
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
{% trans "This stock item is in production and cannot be edited." %}<br>
|
||||||
|
{% trans "Edit the stock item from the build view." %}<br>
|
||||||
|
|
||||||
|
{% if item.build %}
|
||||||
|
<a href="{% url 'build-detail' item.build.id %}">
|
||||||
|
<b>{{ item.build }}</b>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if item.hasRequiredTests and not item.passedAllRequiredTests %}
|
{% if item.hasRequiredTests and not item.passedAllRequiredTests %}
|
||||||
<div class='alert alert-block alert-danger'>
|
<div class='alert alert-block alert-danger'>
|
||||||
{% trans "This stock item has not passed all required tests" %}
|
{% trans "This stock item has not passed all required tests" %}
|
||||||
@ -23,13 +37,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
|
|
||||||
{% for allocation in item.sales_order_allocations.all %}
|
{% for allocation in item.sales_order_allocations.all %}
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
{% trans "This stock item is allocated to Sales Order" %} <a href="{% url 'so-detail' allocation.line.order.id %}"><b>#{{ allocation.line.order.reference }}</b></a> ({% trans "Quantity" %}: {% decimal allocation.quantity %})
|
{% trans "This stock item is allocated to Sales Order" %} <a href="{% url 'so-detail' allocation.line.order.id %}"><b>#{{ allocation.line.order }}</b></a> ({% trans "Quantity" %}: {% decimal allocation.quantity %})
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% for allocation in item.allocations.all %}
|
{% for allocation in item.allocations.all %}
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
{% trans "This stock item is allocated to Build" %} <a href="{% url 'build-detail' allocation.build.id %}"><b>#{{ allocation.build.id }}</b></a> ({% trans "Quantity" %}: {% decimal allocation.quantity %})
|
{% trans "This stock item is allocated to Build" %} <a href="{% url 'build-detail' allocation.build.id %}"><b>#{{ allocation.build }}</b></a> ({% trans "Quantity" %}: {% decimal allocation.quantity %})
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
@ -79,7 +93,6 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
|
|
||||||
<div class='btn-group' role='group'>
|
<div class='btn-group' role='group'>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='btn-group action-buttons' role='group'>
|
<div class='btn-group action-buttons' role='group'>
|
||||||
@ -99,7 +112,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<!-- Stock adjustment menu -->
|
<!-- Stock adjustment menu -->
|
||||||
{% if roles.stock.change %}
|
{% if roles.stock.change and not item.is_building %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='stock-options' title='{% trans "Stock adjustment actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
|
<button id='stock-options' title='{% trans "Stock adjustment actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
|
||||||
<ul class='dropdown-menu' role='menu'>
|
<ul class='dropdown-menu' role='menu'>
|
||||||
@ -129,7 +142,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Edit stock item -->
|
<!-- Edit stock item -->
|
||||||
{% if roles.stock.change %}
|
{% if roles.stock.change and not item.is_building %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='stock-edit-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-tools'></span> <span class='caret'></span></button>
|
<button id='stock-edit-actions' title='{% trans "Stock 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'>
|
<ul class='dropdown-menu' role='menu'>
|
||||||
@ -221,7 +234,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
{% if item.location %}
|
{% if item.location %}
|
||||||
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
|
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td>{% trans "No location set" %}</td>
|
<td><i>{% trans "No location set" %}</i></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -290,7 +303,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
{% if item.stocktake_date %}
|
{% if item.stocktake_date %}
|
||||||
<td>{{ item.stocktake_date }} <span class='badge'>{{ item.stocktake_user }}</span></td>
|
<td>{{ item.stocktake_date }} <span class='badge'>{{ item.stocktake_user }}</span></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td>{% trans "No stocktake performed" %}</td>
|
<td><i>{% trans "No stocktake performed" %}</i></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -197,6 +197,10 @@ class StockTest(TestCase):
|
|||||||
def test_partial_move(self):
|
def test_partial_move(self):
|
||||||
w1 = StockItem.objects.get(pk=100)
|
w1 = StockItem.objects.get(pk=100)
|
||||||
|
|
||||||
|
# A batch code is required to split partial stock!
|
||||||
|
w1.batch = 'BW1'
|
||||||
|
w1.save()
|
||||||
|
|
||||||
# Move 6 of the units
|
# Move 6 of the units
|
||||||
self.assertTrue(w1.move(self.diningroom, 'Moved', None, quantity=6))
|
self.assertTrue(w1.move(self.diningroom, 'Moved', None, quantity=6))
|
||||||
|
|
||||||
@ -339,6 +343,7 @@ class StockTest(TestCase):
|
|||||||
# Item will deplete when deleted
|
# Item will deplete when deleted
|
||||||
item = StockItem.objects.get(pk=100)
|
item = StockItem.objects.get(pk=100)
|
||||||
item.delete_on_deplete = True
|
item.delete_on_deplete = True
|
||||||
|
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
n = StockItem.objects.filter(part=25).count()
|
n = StockItem.objects.filter(part=25).count()
|
||||||
@ -523,6 +528,7 @@ class TestResultTest(StockTest):
|
|||||||
item.pk = None
|
item.pk = None
|
||||||
item.serial = None
|
item.serial = None
|
||||||
item.quantity = 50
|
item.quantity = 50
|
||||||
|
item.batch = "B344"
|
||||||
|
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ from InvenTree.views import InvenTreeRoleMixin
|
|||||||
from InvenTree.forms import ConfirmForm
|
from InvenTree.forms import ConfirmForm
|
||||||
|
|
||||||
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
|
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
|
||||||
from InvenTree.helpers import ExtractSerialNumbers
|
from InvenTree.helpers import extract_serial_numbers
|
||||||
|
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -164,9 +164,10 @@ class StockItemAttachmentCreate(AjaxCreateView):
|
|||||||
ajax_template_name = "modal_form.html"
|
ajax_template_name = "modal_form.html"
|
||||||
role_required = 'stock.add'
|
role_required = 'stock.add'
|
||||||
|
|
||||||
def post_save(self, attachment, form, **kwargs):
|
def save(self, form, **kwargs):
|
||||||
""" Record the user that uploaded the attachment """
|
""" Record the user that uploaded the attachment """
|
||||||
|
|
||||||
|
attachment = form.save(commit=False)
|
||||||
attachment.user = self.request.user
|
attachment.user = self.request.user
|
||||||
attachment.save()
|
attachment.save()
|
||||||
|
|
||||||
@ -252,7 +253,7 @@ class StockItemAssignToCustomer(AjaxUpdateView):
|
|||||||
if not customer:
|
if not customer:
|
||||||
form.add_error('customer', _('Customer must be specified'))
|
form.add_error('customer', _('Customer must be specified'))
|
||||||
|
|
||||||
def post_save(self, item, form, **kwargs):
|
def save(self, item, form, **kwargs):
|
||||||
"""
|
"""
|
||||||
Assign the stock item to the customer.
|
Assign the stock item to the customer.
|
||||||
"""
|
"""
|
||||||
@ -286,7 +287,7 @@ class StockItemReturnToStock(AjaxUpdateView):
|
|||||||
if not location:
|
if not location:
|
||||||
form.add_error('location', _('Specify a valid location'))
|
form.add_error('location', _('Specify a valid location'))
|
||||||
|
|
||||||
def post_save(self, item, form, **kwargs):
|
def save(self, item, form, **kwargs):
|
||||||
|
|
||||||
location = form.cleaned_data.get('location', None)
|
location = form.cleaned_data.get('location', None)
|
||||||
|
|
||||||
@ -431,9 +432,12 @@ class StockItemTestResultCreate(AjaxCreateView):
|
|||||||
ajax_form_title = _("Add Test Result")
|
ajax_form_title = _("Add Test Result")
|
||||||
role_required = 'stock.add'
|
role_required = 'stock.add'
|
||||||
|
|
||||||
def post_save(self, result, form, **kwargs):
|
def save(self, form, **kwargs):
|
||||||
""" Record the user that uploaded the test result """
|
"""
|
||||||
|
Record the user that uploaded the test result
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = form.save(commit=False)
|
||||||
result.user = self.request.user
|
result.user = self.request.user
|
||||||
result.save()
|
result.save()
|
||||||
|
|
||||||
@ -1405,7 +1409,7 @@ class StockItemSerialize(AjaxUpdateView):
|
|||||||
destination = None
|
destination = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
numbers = ExtractSerialNumbers(serials, quantity)
|
numbers = extract_serial_numbers(serials, quantity)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
form.add_error('serial_numbers', e.messages)
|
form.add_error('serial_numbers', e.messages)
|
||||||
valid = False
|
valid = False
|
||||||
@ -1501,11 +1505,8 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
# form.fields['part'].widget = HiddenInput()
|
# form.fields['part'].widget = HiddenInput()
|
||||||
|
|
||||||
# Trackable parts get special consideration:
|
# Trackable parts get special consideration:
|
||||||
if part.trackable:
|
form.fields['delete_on_deplete'].disabled = not part.trackable
|
||||||
form.fields['delete_on_deplete'].widget = HiddenInput()
|
form.fields['serial_numbers'].disabled = not part.trackable
|
||||||
form.fields['delete_on_deplete'].initial = False
|
|
||||||
else:
|
|
||||||
form.fields['serial_numbers'].widget = HiddenInput()
|
|
||||||
|
|
||||||
# If the part is NOT purchaseable, hide the supplier_part field
|
# If the part is NOT purchaseable, hide the supplier_part field
|
||||||
if not part.purchaseable:
|
if not part.purchaseable:
|
||||||
@ -1530,6 +1531,8 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
# We must not provide *any* options for SupplierPart
|
# We must not provide *any* options for SupplierPart
|
||||||
form.fields['supplier_part'].queryset = SupplierPart.objects.none()
|
form.fields['supplier_part'].queryset = SupplierPart.objects.none()
|
||||||
|
|
||||||
|
form.fields['serial_numbers'].disabled = True
|
||||||
|
|
||||||
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
|
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
|
||||||
if form['supplier_part'].value() is not None:
|
if form['supplier_part'].value() is not None:
|
||||||
pass
|
pass
|
||||||
@ -1631,7 +1634,7 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
# If user has specified a range of serial numbers
|
# If user has specified a range of serial numbers
|
||||||
if len(sn) > 0:
|
if len(sn) > 0:
|
||||||
try:
|
try:
|
||||||
serials = ExtractSerialNumbers(sn, quantity)
|
serials = extract_serial_numbers(sn, quantity)
|
||||||
|
|
||||||
existing = part.find_conflicting_serial_numbers(serials)
|
existing = part.find_conflicting_serial_numbers(serials)
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block collapse_title %}
|
{% block collapse_title %}
|
||||||
<span class='fas fa-times-circle icon-header'></span>
|
<span class='fas fa-times-circle icon-header'></span>
|
||||||
{% trans "BOM Waiting Validation" %}<span class='badge' id='bom-invalid-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
|
{% trans "BOM Waiting Validation" %}<span class='badge' id='bom-invalid-count'><span class='fas fa-spin fa-spinner'></span></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block collapse_content %}
|
{% block collapse_content %}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block collapse_title %}
|
{% block collapse_title %}
|
||||||
<span class='fas fa-cogs icon-header'></span>
|
<span class='fas fa-cogs icon-header'></span>
|
||||||
{% trans "Pending Builds" %}<span class='badge' id='build-pending-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
|
{% trans "Pending Builds" %}<span class='badge' id='build-pending-count'><span class='fas fa-spin fa-spinner'></span></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block collapse_content %}
|
{% block collapse_content %}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block collapse_title %}
|
{% block collapse_title %}
|
||||||
<span class='fas fa-newspaper icon-header'></span>
|
<span class='fas fa-newspaper icon-header'></span>
|
||||||
{% trans "Latest Parts" %}<span class='badge' id='latest-parts-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
|
{% trans "Latest Parts" %}<span class='badge' id='latest-parts-count'><span class='fas fa-spin fa-spinner'></span></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block collapse_content %}
|
{% block collapse_content %}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block collapse_title %}
|
{% block collapse_title %}
|
||||||
<span class='fas fa-shopping-cart icon-header'></span>
|
<span class='fas fa-shopping-cart icon-header'></span>
|
||||||
{% trans "Low Stock" %}<span class='badge' id='low-stock-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
|
{% trans "Low Stock" %}<span class='badge' id='low-stock-count'><span class='fas fa-spin fa-spinner'></span></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block collapse_content %}
|
{% block collapse_content %}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block collapse_title %}
|
{% block collapse_title %}
|
||||||
<span class='fas fa-sign-in-alt icon-header'></span>
|
<span class='fas fa-sign-in-alt icon-header'></span>
|
||||||
{% trans "Outstanding Purchase Orders" %}<span class='badge' id='po-outstanding-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
|
{% trans "Outstanding Purchase Orders" %}<span class='badge' id='po-outstanding-count'><span class='fas fa-spin fa-spinner'></span></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block collapse_content %}
|
{% block collapse_content %}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block collapse_title %}
|
{% block collapse_title %}
|
||||||
<span class='fas fa-bullhorn icon-header'></span>
|
<span class='fas fa-bullhorn icon-header'></span>
|
||||||
{% trans "Require Stock To Complete Build" %}<span class='badge' id='stock-to-build-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
|
{% trans "Require Stock To Complete Build" %}<span class='badge' id='stock-to-build-count'><span class='fas fa-spin fa-spinner'></span></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block collapse_content %}
|
{% block collapse_content %}
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
<span class='fas fa-spin fa-hourglass-half'></span> <i>{% trans "Searching" %}</i>
|
<span class='fas fa-spin fa-spinner'></span> <i>{% trans "Searching" %}</i>
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block collapse_title %}
|
{% block collapse_title %}
|
||||||
<span class='fas fa-sign-out-alt icon-header'></span>
|
<span class='fas fa-sign-out-alt icon-header'></span>
|
||||||
{% trans "Outstanding Sales Orders" %}<span class='badge' id='so-outstanding-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
|
{% trans "Outstanding Sales Orders" %}<span class='badge' id='so-outstanding-count'><span class='fas fa-spin fa-spinner'></span></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block collapse_content %}
|
{% block collapse_content %}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block collapse_title %}
|
{% block collapse_title %}
|
||||||
<span class='fas fa-star icon-header'></span>
|
<span class='fas fa-star icon-header'></span>
|
||||||
{% trans "Starred Parts" %}<span class='badge' id='starred-parts-count'><span class='fas fa-spin fa-hourglass-half'></span></span>
|
{% trans "Starred Parts" %}<span class='badge' id='starred-parts-count'><span class='fas fa-spin fa-spinner'></span></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block collapse_content %}
|
{% block collapse_content %}
|
||||||
|
@ -152,17 +152,7 @@ function loadBomTable(table, options) {
|
|||||||
|
|
||||||
var sub_part = row.sub_part_detail;
|
var sub_part = row.sub_part_detail;
|
||||||
|
|
||||||
if (sub_part.trackable) {
|
html += makePartIcons(row.sub_part_detail);
|
||||||
html += makeIconBadge('fa-directions', '{% trans "Trackable part" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub_part.virtual) {
|
|
||||||
html += makeIconBadge('fa-ghost', '{% trans "Virtual part" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub_part.is_template) {
|
|
||||||
html += makeIconBadge('fa-clone', '{% trans "Template part" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display an extra icon if this part is an assembly
|
// Display an extra icon if this part is an assembly
|
||||||
if (sub_part.assembly) {
|
if (sub_part.assembly) {
|
||||||
@ -171,10 +161,6 @@ function loadBomTable(table, options) {
|
|||||||
html += renderLink(text, `/part/${row.sub_part}/bom/`);
|
html += renderLink(text, `/part/${row.sub_part}/bom/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sub_part.active) {
|
|
||||||
html += `<span class='label label-warning label-right'>{% trans "Inactive" %}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -307,8 +293,6 @@ function loadBomTable(table, options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure the table (bootstrap-table)
|
|
||||||
|
|
||||||
// Function to request BOM data for sub-items
|
// Function to request BOM data for sub-items
|
||||||
// This function may be called recursively for multi-level BOMs
|
// This function may be called recursively for multi-level BOMs
|
||||||
function requestSubItems(bom_pk, part_pk) {
|
function requestSubItems(bom_pk, part_pk) {
|
||||||
|
@ -1,6 +1,592 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
function newBuildOrder(options={}) {
|
||||||
|
/* Launch modal form to create a new BuildOrder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
launchModalForm(
|
||||||
|
"{% url 'build-create' %}",
|
||||||
|
{
|
||||||
|
follow: true,
|
||||||
|
data: options.data || {},
|
||||||
|
callback: [
|
||||||
|
{
|
||||||
|
field: 'part',
|
||||||
|
action: function(value) {
|
||||||
|
inventreeGet(
|
||||||
|
`/api/part/${value}/`, {},
|
||||||
|
{
|
||||||
|
success: function(response) {
|
||||||
|
|
||||||
|
//enableField('serial_numbers', response.trackable);
|
||||||
|
//clearField('serial_numbers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function makeBuildOutputActionButtons(output, buildInfo) {
|
||||||
|
/* Generate action buttons for a build output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var buildId = buildInfo.pk;
|
||||||
|
var 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}`);
|
||||||
|
|
||||||
|
var html = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
// Add a button to "auto allocate" against the build
|
||||||
|
html += makeIconButton(
|
||||||
|
'fa-magic icon-blue', 'button-output-auto', outputId,
|
||||||
|
'{% trans "Auto-allocate stock items to this 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 "cancel" the particular build output (unallocate)
|
||||||
|
html += makeIconButton(
|
||||||
|
'fa-minus-circle icon-red', 'button-output-unallocate', outputId,
|
||||||
|
'{% trans "Unallocate stock from 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" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a button to "destroy" the particular build output (mark as damaged, scrap)
|
||||||
|
// TODO
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
buildActions.html(html);
|
||||||
|
|
||||||
|
// Add callbacks for the buttons
|
||||||
|
$(panel).find(`#button-output-auto-${outputId}`).click(function() {
|
||||||
|
// Launch modal dialog to perform auto-allocation
|
||||||
|
launchModalForm(`/build/${buildId}/auto-allocate/`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
output: outputId,
|
||||||
|
},
|
||||||
|
success: reloadTable,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(panel).find(`#button-output-complete-${outputId}`).click(function() {
|
||||||
|
launchModalForm(
|
||||||
|
`/build/${buildId}/complete-output/`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
output: outputId,
|
||||||
|
},
|
||||||
|
reload: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(panel).find(`#button-output-unallocate-${outputId}`).click(function() {
|
||||||
|
launchModalForm(
|
||||||
|
`/build/${buildId}/unallocate/`,
|
||||||
|
{
|
||||||
|
success: reloadTable,
|
||||||
|
data: {
|
||||||
|
output: outputId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
|
||||||
|
launchModalForm(
|
||||||
|
`/build/${buildId}/delete-output/`,
|
||||||
|
{
|
||||||
|
reload: true,
|
||||||
|
data: {
|
||||||
|
output: outputId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
*/
|
||||||
|
|
||||||
|
var buildId = buildInfo.pk;
|
||||||
|
var partId = buildInfo.part;
|
||||||
|
|
||||||
|
var outputId = null;
|
||||||
|
|
||||||
|
outputId = output.pk;
|
||||||
|
|
||||||
|
var table = options.table;
|
||||||
|
|
||||||
|
if (options.table == null) {
|
||||||
|
table = `#allocation-table-${outputId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadTable() {
|
||||||
|
// Reload the entire build allocation table
|
||||||
|
$(table).bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredQuantity(row) {
|
||||||
|
// Return the requied quantity for a given row
|
||||||
|
|
||||||
|
return row.quantity * output.quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumAllocations(row) {
|
||||||
|
// Calculat total allocations for a given row
|
||||||
|
if (!row.allocations) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var quantity = 0;
|
||||||
|
|
||||||
|
row.allocations.forEach(function(item) {
|
||||||
|
quantity += item.quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCallbacks() {
|
||||||
|
// Register button callbacks once table data are loaded
|
||||||
|
|
||||||
|
// Callback for 'allocate' button
|
||||||
|
$(table).find(".button-add").click(function() {
|
||||||
|
|
||||||
|
// Primary key of the 'sub_part'
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
// Launch form to allocate new stock against this output
|
||||||
|
launchModalForm("{% url 'build-item-create' %}", {
|
||||||
|
success: reloadTable,
|
||||||
|
data: {
|
||||||
|
part: pk,
|
||||||
|
build: buildId,
|
||||||
|
install_into: outputId,
|
||||||
|
},
|
||||||
|
secondary: [
|
||||||
|
{
|
||||||
|
field: 'stock_item',
|
||||||
|
label: '{% trans "New Stock Item" %}',
|
||||||
|
title: '{% trans "Create new Stock Item" %}',
|
||||||
|
url: '{% url "stock-item-create" %}',
|
||||||
|
data: {
|
||||||
|
part: pk,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
callback: [
|
||||||
|
{
|
||||||
|
field: 'stock_item',
|
||||||
|
action: function(value) {
|
||||||
|
inventreeGet(
|
||||||
|
`/api/stock/${value}/`, {},
|
||||||
|
{
|
||||||
|
success: function(response) {
|
||||||
|
|
||||||
|
// How many items are actually available for the given stock item?
|
||||||
|
var available = response.quantity - response.allocated;
|
||||||
|
|
||||||
|
var field = getFieldByName('#modal-form', 'quantity');
|
||||||
|
|
||||||
|
// Allocation quantity initial value
|
||||||
|
var initial = field.attr('value');
|
||||||
|
|
||||||
|
if (available < initial) {
|
||||||
|
field.val(available);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for 'build' button
|
||||||
|
$(table).find('.button-build').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
// Extract row data from the table
|
||||||
|
var idx = $(this).closest('tr').attr('data-index');
|
||||||
|
var row = $(table).bootstrapTable('getData')[idx];
|
||||||
|
|
||||||
|
// Launch form to create a new build order
|
||||||
|
launchModalForm('{% url "build-create" %}', {
|
||||||
|
follow: true,
|
||||||
|
data: {
|
||||||
|
part: pk,
|
||||||
|
parent: buildId,
|
||||||
|
quantity: requiredQuantity(row) - sumAllocations(row),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for 'unallocate' button
|
||||||
|
$(table).find('.button-unallocate').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
launchModalForm(`/build/${buildId}/unallocate/`,
|
||||||
|
{
|
||||||
|
success: reloadTable,
|
||||||
|
data: {
|
||||||
|
output: outputId,
|
||||||
|
part: pk,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load table of BOM items
|
||||||
|
$(table).inventreeTable({
|
||||||
|
url: "{% url 'api-bom-list' %}",
|
||||||
|
queryParams: {
|
||||||
|
part: partId,
|
||||||
|
sub_part_detail: true,
|
||||||
|
},
|
||||||
|
formatNoMatches: function() {
|
||||||
|
return '{% trans "No BOM items found" %}';
|
||||||
|
},
|
||||||
|
name: 'build-allocation',
|
||||||
|
uniqueId: 'sub_part',
|
||||||
|
onPostBody: setupCallbacks,
|
||||||
|
onLoadSuccess: function(tableData) {
|
||||||
|
// Once the BOM data are loaded, request allocation data for this build output
|
||||||
|
|
||||||
|
inventreeGet('/api/build/item/',
|
||||||
|
{
|
||||||
|
build: buildId,
|
||||||
|
output: outputId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: function(data) {
|
||||||
|
// Iterate through the returned data, and group by the part they point to
|
||||||
|
var allocations = {};
|
||||||
|
|
||||||
|
// Total number of line items
|
||||||
|
var totalLines = tableData.length;
|
||||||
|
|
||||||
|
// Total number of "completely allocated" lines
|
||||||
|
var allocatedLines = 0;
|
||||||
|
|
||||||
|
data.forEach(function(item) {
|
||||||
|
|
||||||
|
// Group BuildItem objects by part
|
||||||
|
var part = item.part;
|
||||||
|
var key = parseInt(part);
|
||||||
|
|
||||||
|
if (!(key in allocations)) {
|
||||||
|
allocations[key] = new Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
allocations[key].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now update the allocations for each row in the table
|
||||||
|
for (var key in allocations) {
|
||||||
|
|
||||||
|
// Select the associated row in the table
|
||||||
|
var tableRow = $(table).bootstrapTable('getRowByUniqueId', key);
|
||||||
|
|
||||||
|
if (!tableRow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the allocation list for that row
|
||||||
|
tableRow.allocations = allocations[key];
|
||||||
|
|
||||||
|
// Calculate the total allocated quantity
|
||||||
|
var allocatedQuantity = sumAllocations(tableRow);
|
||||||
|
|
||||||
|
// Is this line item fully allocated?
|
||||||
|
if (allocatedQuantity >= (tableRow.quantity * output.quantity)) {
|
||||||
|
allocatedLines += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the updated row back into the main table
|
||||||
|
$(table).bootstrapTable('updateByUniqueId', key, tableRow, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the total progress for this build output
|
||||||
|
var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`));
|
||||||
|
|
||||||
|
var progress = makeProgressBar(
|
||||||
|
allocatedLines,
|
||||||
|
totalLines
|
||||||
|
);
|
||||||
|
|
||||||
|
buildProgress.html(progress);
|
||||||
|
|
||||||
|
// Update the available actions for this build output
|
||||||
|
|
||||||
|
makeBuildOutputActionButtons(output, buildInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortable: true,
|
||||||
|
showColumns: false,
|
||||||
|
detailViewByClick: true,
|
||||||
|
detailView: true,
|
||||||
|
detailFilter: function(index, row) {
|
||||||
|
return row.allocations != null;
|
||||||
|
},
|
||||||
|
detailFormatter: function(index, row, element) {
|
||||||
|
// Contruct an 'inner table' which shows which stock items have been allocated
|
||||||
|
|
||||||
|
var subTableId = `allocation-table-${row.pk}`;
|
||||||
|
|
||||||
|
var html = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
|
||||||
|
|
||||||
|
element.html(html);
|
||||||
|
|
||||||
|
var lineItem = row;
|
||||||
|
|
||||||
|
var subTable = $(`#${subTableId}`);
|
||||||
|
|
||||||
|
subTable.bootstrapTable({
|
||||||
|
data: row.allocations,
|
||||||
|
showHeader: true,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
width: '50%',
|
||||||
|
field: 'quantity',
|
||||||
|
title: '{% trans "Assigned Stock" %}',
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
var text = '';
|
||||||
|
|
||||||
|
var url = '';
|
||||||
|
|
||||||
|
if (row.serial && row.quantity == 1) {
|
||||||
|
text = `{% trans "Serial Number" %}: ${row.serial}`;
|
||||||
|
} else {
|
||||||
|
text = `{% trans "Quantity" %}: ${row.quantity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if build.status == BuildStatus.COMPLETE %}
|
||||||
|
url = `/stock/item/${row.pk}/`;
|
||||||
|
{% else %}
|
||||||
|
url = `/stock/item/${row.stock_item}/`;
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
return renderLink(text, url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'location',
|
||||||
|
title: '{% trans "Location" %}',
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
if (row.stock_item_detail.location) {
|
||||||
|
var text = row.stock_item_detail.location_name;
|
||||||
|
var url = `/stock/location/${row.stock_item_detail.location}/`;
|
||||||
|
|
||||||
|
return renderLink(text, url);
|
||||||
|
} else {
|
||||||
|
return '<i>{% trans "No location set" %}</i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'actions',
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
/* Actions available for a particular stock item allocation:
|
||||||
|
*
|
||||||
|
* - Edit the allocation quantity
|
||||||
|
* - Delete the allocation
|
||||||
|
*/
|
||||||
|
|
||||||
|
var pk = row.pk;
|
||||||
|
|
||||||
|
var html = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
|
||||||
|
|
||||||
|
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign button callbacks to the newly created allocation buttons
|
||||||
|
subTable.find('.button-allocation-edit').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
launchModalForm(`/build/item/${pk}/edit/`, {
|
||||||
|
success: reloadTable,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
subTable.find('.button-allocation-delete').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
launchModalForm(`/build/item/${pk}/delete/`, {
|
||||||
|
success: reloadTable,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
field: 'pk',
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'sub_part_detail.full_name',
|
||||||
|
title: '{% trans "Required Part" %}',
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
var url = `/part/${row.sub_part}/`;
|
||||||
|
var thumb = row.sub_part_detail.thumbnail;
|
||||||
|
var name = row.sub_part_detail.full_name;
|
||||||
|
|
||||||
|
var html = imageHoverIcon(thumb) + renderLink(name, url);
|
||||||
|
|
||||||
|
html += makePartIcons(row.sub_part_detail);
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'reference',
|
||||||
|
title: '{% trans "Reference" %}',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'quantity',
|
||||||
|
title: '{% trans "Quantity Per" %}',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'sub_part_detail.stock',
|
||||||
|
title: '{% trans "Available" %}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'allocated',
|
||||||
|
title: '{% trans "Allocated" %}',
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
var allocated = 0;
|
||||||
|
|
||||||
|
if (row.allocations) {
|
||||||
|
row.allocations.forEach(function(item) {
|
||||||
|
allocated += item.quantity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var required = requiredQuantity(row);
|
||||||
|
|
||||||
|
return makeProgressBar(allocated, required);
|
||||||
|
},
|
||||||
|
sorter: function(valA, valB, rowA, rowB) {
|
||||||
|
var aA = sumAllocations(rowA);
|
||||||
|
var aB = sumAllocations(rowB);
|
||||||
|
|
||||||
|
var qA = rowA.quantity;
|
||||||
|
var qB = rowB.quantity;
|
||||||
|
|
||||||
|
qA *= output.quantity;
|
||||||
|
qB *= output.quantity;
|
||||||
|
|
||||||
|
// Handle the case where both numerators are zero
|
||||||
|
if ((aA == 0) && (aB == 0)) {
|
||||||
|
return (qA > qB) ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the case where either denominator is zero
|
||||||
|
if ((qA == 0) || (qB == 0)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressA = parseFloat(aA) / qA;
|
||||||
|
var progressB = parseFloat(aB) / qB;
|
||||||
|
|
||||||
|
// Handle the case where both ratios are equal
|
||||||
|
if (progressA == progressB) {
|
||||||
|
return (qA < qB) ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (progressA < progressB) ? 1 : -1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'actions',
|
||||||
|
title: '{% trans "Actions" %}',
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
// Generate action buttons for this build output
|
||||||
|
var html = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
if (sumAllocations(row) < requiredQuantity(row)) {
|
||||||
|
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" %}', {disabled: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', row.sub_part, '{% trans "Allocate stock" %}');
|
||||||
|
}
|
||||||
|
|
||||||
|
html += makeIconButton(
|
||||||
|
'fa-minus-circle icon-red', 'button-unallocate', row.sub_part,
|
||||||
|
'{% trans "Unallocate stock" %}',
|
||||||
|
{
|
||||||
|
disabled: row.allocations == null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function loadBuildTable(table, options) {
|
function loadBuildTable(table, options) {
|
||||||
// Display a table of Build objects
|
// Display a table of Build objects
|
||||||
|
|
||||||
@ -9,7 +595,7 @@ function loadBuildTable(table, options) {
|
|||||||
var filters = {};
|
var filters = {};
|
||||||
|
|
||||||
if (!options.disableFilters) {
|
if (!options.disableFilters) {
|
||||||
loadTableFilters("build");
|
filters = loadTableFilters("build");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var key in params) {
|
for (var key in params) {
|
||||||
@ -21,7 +607,7 @@ function loadBuildTable(table, options) {
|
|||||||
$(table).inventreeTable({
|
$(table).inventreeTable({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
return "{% trans "No builds matching query" %}";
|
return '{% trans "No builds matching query" %}';
|
||||||
},
|
},
|
||||||
url: options.url,
|
url: options.url,
|
||||||
queryParams: filters,
|
queryParams: filters,
|
||||||
@ -62,15 +648,27 @@ function loadBuildTable(table, options) {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
|
|
||||||
var name = row.part_detail.full_name;
|
var html = imageHoverIcon(row.part_detail.thumbnail);
|
||||||
|
|
||||||
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(name, '/part/' + row.part + '/');
|
html += renderLink(row.part_detail.full_name, `/part/${row.part}/`);
|
||||||
|
html += makePartIcons(row.part_detail);
|
||||||
|
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'quantity',
|
field: 'quantity',
|
||||||
title: '{% trans "Quantity" %}',
|
title: '{% trans "Completed" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
return makeProgressBar(
|
||||||
|
row.completed,
|
||||||
|
row.quantity,
|
||||||
|
{
|
||||||
|
//style: 'max',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'status',
|
field: 'status',
|
||||||
|
@ -61,6 +61,45 @@ function toggleStar(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function makePartIcons(part, options={}) {
|
||||||
|
/* Render a set of icons for the given part.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
if (part.trackable) {
|
||||||
|
html += makeIconBadge('fa-directions', '{% trans "Trackable part" %}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.virtual) {
|
||||||
|
html += makeIconBadge('fa-ghost', '{% trans "Virtual part" %}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.is_template) {
|
||||||
|
html += makeIconBadge('fa-clone', '{% trans "Template part" %}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.assembly) {
|
||||||
|
html += makeIconBadge('fa-tools', '{% trans "Assembled part" %}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.starred) {
|
||||||
|
html += makeIconBadge('fa-star', '{% trans "Starred part" %}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.salable) {
|
||||||
|
html += makeIconBadge('fa-dollar-sign', title='{% trans "Salable part" %}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!part.active) {
|
||||||
|
html += `<span class='label label-warning label-right'>{% trans "Inactive" %}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function loadPartVariantTable(table, partId, options={}) {
|
function loadPartVariantTable(table, partId, options={}) {
|
||||||
/* Load part variant table
|
/* Load part variant table
|
||||||
*/
|
*/
|
||||||
@ -340,40 +379,8 @@ function loadPartTable(table, url, options={}) {
|
|||||||
|
|
||||||
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
|
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
|
||||||
|
|
||||||
if (row.trackable) {
|
display += makePartIcons(row);
|
||||||
display += makeIconBadge('fa-directions', '{% trans "Trackable part" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.virtual) {
|
|
||||||
display += makeIconBadge('fa-ghost', '{% trans "Virtual part" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (row.is_template) {
|
|
||||||
display += makeIconBadge('fa-clone', '{% trans "Template part" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.assembly) {
|
|
||||||
display += makeIconBadge('fa-tools', '{% trans "Assembled part" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.starred) {
|
|
||||||
display += makeIconBadge('fa-star', '{% trans "Starred part" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.salable) {
|
|
||||||
display += makeIconBadge('fa-dollar-sign', title='{% trans "Salable part" %}');
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
if (row.component) {
|
|
||||||
display = display + `<span class='fas fa-cogs label-right' title='Component part'></span>`;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (!row.active) {
|
|
||||||
display += `<span class='label label-warning label-right'>{% trans "Inactive" %}</span>`;
|
|
||||||
}
|
|
||||||
return display;
|
return display;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -257,6 +257,56 @@ function loadStockTable(table, options) {
|
|||||||
filters[key] = params[key];
|
filters[key] = params[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function locationDetail(row) {
|
||||||
|
/*
|
||||||
|
* Function to display a "location" of a StockItem.
|
||||||
|
*
|
||||||
|
* Complicating factors: A StockItem may not actually *be* in a location!
|
||||||
|
* - Could be at a customer
|
||||||
|
* - Could be installed in another stock item
|
||||||
|
* - Could be assigned to a sales order
|
||||||
|
* - Could be currently in production!
|
||||||
|
*
|
||||||
|
* So, instead of being naive, we'll check!
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Display text
|
||||||
|
var text = '';
|
||||||
|
|
||||||
|
// URL (optional)
|
||||||
|
var url = '';
|
||||||
|
|
||||||
|
if (row.is_building && row.build) {
|
||||||
|
// StockItem is currently being built!
|
||||||
|
text = "{% trans "In production" %}";
|
||||||
|
url = `/build/${row.build}/`;
|
||||||
|
} else if (row.belongs_to) {
|
||||||
|
// StockItem is installed inside a different StockItem
|
||||||
|
text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
|
||||||
|
url = `/stock/item/${row.belongs_to}/installed/`;
|
||||||
|
} else if (row.customer) {
|
||||||
|
// StockItem has been assigned to a customer
|
||||||
|
text = "{% trans "Shipped to customer" %}";
|
||||||
|
url = `/company/${row.customer}/assigned-stock/`;
|
||||||
|
} else if (row.sales_order) {
|
||||||
|
// StockItem has been assigned to a sales order
|
||||||
|
text = "{% trans "Assigned to Sales Order" %}";
|
||||||
|
url = `/order/sales-order/${row.sales_order}/`;
|
||||||
|
} else if (row.location) {
|
||||||
|
text = row.location_detail.pathstring;
|
||||||
|
url = `/stock/location/${row.location}/`;
|
||||||
|
} else {
|
||||||
|
text = "<i>{% trans "No stock location set" %}</i>";
|
||||||
|
url = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return renderLink(text, url);
|
||||||
|
} else {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
table.inventreeTable({
|
table.inventreeTable({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
@ -274,11 +324,16 @@ function loadStockTable(table, options) {
|
|||||||
|
|
||||||
var row = data[0];
|
var row = data[0];
|
||||||
|
|
||||||
if (field == 'part_detail.name') {
|
if (field == 'part_detail.full_name') {
|
||||||
|
|
||||||
var name = row.part_detail.full_name;
|
var html = imageHoverIcon(row.part_detail.thumbnail);
|
||||||
|
|
||||||
return imageHoverIcon(row.part_detail.thumbnail) + name + ' <i>(' + data.length + ' items)</i>';
|
html += row.part_detail.full_name;
|
||||||
|
html += ` <i>(${data.length} items)</i>`;
|
||||||
|
|
||||||
|
html += makePartIcons(row.part_detail);
|
||||||
|
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
else if (field == 'part_detail.IPN') {
|
else if (field == 'part_detail.IPN') {
|
||||||
return row.part_detail.IPN;
|
return row.part_detail.IPN;
|
||||||
@ -353,29 +408,21 @@ function loadStockTable(table, options) {
|
|||||||
|
|
||||||
data.forEach(function(item) {
|
data.forEach(function(item) {
|
||||||
|
|
||||||
var loc = null;
|
var detail = locationDetail(item);
|
||||||
|
|
||||||
if (item.location_detail) {
|
if (!locations.includes(detail)) {
|
||||||
loc = item.location_detail.pathstring;
|
locations.push(detail);
|
||||||
} else {
|
|
||||||
loc = "{% trans "Undefined location" %}";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!locations.includes(loc)) {
|
|
||||||
locations.push(loc);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (locations.length > 1) {
|
if (locations.length == 1) {
|
||||||
|
// Single location, easy!
|
||||||
|
return locations[0];
|
||||||
|
} else if (locations.length > 1) {
|
||||||
return "In " + locations.length + " locations";
|
return "In " + locations.length + " locations";
|
||||||
} else {
|
|
||||||
// A single location!
|
|
||||||
if (row.location_detail) {
|
|
||||||
return renderLink(row.location_detail.pathstring, `/stock/location/${row.location}/`);
|
|
||||||
} else {
|
} else {
|
||||||
return "<i>{% trans "Undefined location" %}</i>";
|
return "<i>{% trans "Undefined location" %}</i>";
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if (field == 'notes') {
|
} else if (field == 'notes') {
|
||||||
var notes = [];
|
var notes = [];
|
||||||
|
|
||||||
@ -417,7 +464,7 @@ function loadStockTable(table, options) {
|
|||||||
switchable: false,
|
switchable: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'part_detail.name',
|
field: 'part_detail.full_name',
|
||||||
title: '{% trans "Part" %}',
|
title: '{% trans "Part" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
@ -429,6 +476,8 @@ function loadStockTable(table, options) {
|
|||||||
|
|
||||||
html = imageHoverIcon(thumb) + renderLink(name, url);
|
html = imageHoverIcon(thumb) + renderLink(name, url);
|
||||||
|
|
||||||
|
html += makePartIcons(row.part_detail);
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -465,33 +514,35 @@ function loadStockTable(table, options) {
|
|||||||
|
|
||||||
var html = renderLink(val, `/stock/item/${row.pk}/`);
|
var html = renderLink(val, `/stock/item/${row.pk}/`);
|
||||||
|
|
||||||
if (row.allocated) {
|
if (row.is_building) {
|
||||||
html += `<span class='fas fa-bookmark label-right' title='{% trans "Stock item has been allocated" %}'></span>`;
|
html += makeIconBadge('fa-tools', '{% trans "Stock item is in production" %}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.customer) {
|
if (row.sales_order) {
|
||||||
html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`;
|
// Stock item has been assigned to a sales order
|
||||||
} else {
|
html += makeIconBadge('fa-truck', '{% trans "Stock item assigned to sales order" %}');
|
||||||
if (row.build_order) {
|
} else if (row.customer) {
|
||||||
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
|
// StockItem has been assigned to a customer
|
||||||
} else if (row.sales_order) {
|
html += makeIconBadge('fa-user', '{% trans "Stock item assigned to customer" %}');
|
||||||
html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales order" %}'></span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (row.allocated) {
|
||||||
|
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.belongs_to) {
|
if (row.belongs_to) {
|
||||||
html += `<span class='fas fa-box label-right' title='{% trans "Stock item has been installed in another item" %}'></span>`;
|
html += makeIconBadge('fa-box', '{% trans "Stock item has been installed in another item" %}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special stock status codes
|
// Special stock status codes
|
||||||
|
|
||||||
// 65 = "REJECTED"
|
// 65 = "REJECTED"
|
||||||
if (row.status == 65) {
|
if (row.status == 65) {
|
||||||
html += `<span class='fas fa-times-circle label-right' title='{% trans "Stock item has been rejected" %}'></span>`;
|
html += makeIconButton('fa-times-circle icon-red', '{% trans "Stock item has been rejected" %}');
|
||||||
}
|
}
|
||||||
// 70 = "LOST"
|
// 70 = "LOST"
|
||||||
else if (row.status == 70) {
|
else if (row.status == 70) {
|
||||||
html += `<span class='fas fa-question-circle label-right' title='{% trans "Stock item is lost" %}'></span>`;
|
html += makeIconButton('fa-question-circle','{% trans "Stock item is lost" %}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.quantity <= 0) {
|
if (row.quantity <= 0) {
|
||||||
@ -519,24 +570,7 @@ function loadStockTable(table, options) {
|
|||||||
title: '{% trans "Location" %}',
|
title: '{% trans "Location" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
if (row.belongs_to) {
|
return locationDetail(row);
|
||||||
var text = "{% trans 'Installed in Stock Item ' %}" + row.belongs_to;
|
|
||||||
var url = `/stock/item/${row.belongs_to}/installed/`;
|
|
||||||
|
|
||||||
return renderLink(text, url);
|
|
||||||
} else if (row.customer) {
|
|
||||||
var text = "{% trans "Shipped to customer" %}";
|
|
||||||
return renderLink(text, `/company/${row.customer}/assigned-stock/`);
|
|
||||||
} else if (row.sales_order) {
|
|
||||||
var text = `{% trans "Assigned to sales order" %}`;
|
|
||||||
return renderLink(text, `/order/sales-order/${row.sales_order}/`);
|
|
||||||
}
|
|
||||||
else if (value) {
|
|
||||||
return renderLink(value, `/stock/location/${row.location}/`);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return '<i>{% trans "No stock location set" %}</i>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -769,6 +803,7 @@ function createNewStockItem(options) {
|
|||||||
field: 'part',
|
field: 'part',
|
||||||
action: function(value) {
|
action: function(value) {
|
||||||
|
|
||||||
|
// Reload options for supplier part
|
||||||
reloadFieldOptions(
|
reloadFieldOptions(
|
||||||
'supplier_part',
|
'supplier_part',
|
||||||
{
|
{
|
||||||
@ -782,6 +817,18 @@ function createNewStockItem(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Disable serial number field if the part is not trackable
|
||||||
|
inventreeGet(
|
||||||
|
`/api/part/${value}/`, {},
|
||||||
|
{
|
||||||
|
success: function(response) {
|
||||||
|
|
||||||
|
enableField('serial_numbers', response.trackable);
|
||||||
|
clearField('serial_numbers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -868,7 +915,7 @@ function loadInstalledInTable(table, options) {
|
|||||||
url: "{% url 'api-bom-list' %}",
|
url: "{% url 'api-bom-list' %}",
|
||||||
queryParams: {
|
queryParams: {
|
||||||
part: options.part,
|
part: options.part,
|
||||||
trackable: true,
|
sub_part_trackable: true,
|
||||||
sub_part_detail: true,
|
sub_part_detail: true,
|
||||||
},
|
},
|
||||||
showColumns: false,
|
showColumns: false,
|
||||||
|
@ -65,16 +65,16 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
title: '{% trans "Is Serialized" %}',
|
title: '{% trans "Is Serialized" %}',
|
||||||
},
|
},
|
||||||
serial_gte: {
|
serial_gte: {
|
||||||
title: "{% trans "Serial number GTE" %}",
|
title: '{% trans "Serial number GTE" %}',
|
||||||
description: "{% trans "Serial number greater than or equal to" %}"
|
description: '{% trans "Serial number greater than or equal to" %}'
|
||||||
},
|
},
|
||||||
serial_lte: {
|
serial_lte: {
|
||||||
title: "{% trans "Serial number LTE" %}",
|
title: '{% trans "Serial number LTE" %}',
|
||||||
description: "{% trans "Serial number less than or equal to" %}",
|
description: '{% trans "Serial number less than or equal to" %}',
|
||||||
},
|
},
|
||||||
serial: {
|
serial: {
|
||||||
title: "{% trans "Serial number" %}",
|
title: '{% trans "Serial number" %}',
|
||||||
description: "{% trans "Serial number" %}"
|
description: '{% trans "Serial number" %}'
|
||||||
},
|
},
|
||||||
batch: {
|
batch: {
|
||||||
title: '{% trans "Batch" %}',
|
title: '{% trans "Batch" %}',
|
||||||
@ -111,6 +111,11 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
title: '{% trans "In Stock" %}',
|
title: '{% trans "In Stock" %}',
|
||||||
description: '{% trans "Show items which are in stock" %}',
|
description: '{% trans "Show items which are in stock" %}',
|
||||||
},
|
},
|
||||||
|
is_building: {
|
||||||
|
type: 'bool',
|
||||||
|
title: '{% trans "In Production" %}',
|
||||||
|
description: '{% trans "Show items which are in production" %}',
|
||||||
|
},
|
||||||
installed: {
|
installed: {
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Installed" %}',
|
title: '{% trans "Installed" %}',
|
||||||
@ -126,16 +131,16 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
title: '{% trans "Is Serialized" %}',
|
title: '{% trans "Is Serialized" %}',
|
||||||
},
|
},
|
||||||
serial: {
|
serial: {
|
||||||
title: "{% trans "Serial number" %}",
|
title: '{% trans "Serial number" %}',
|
||||||
description: "{% trans "Serial number" %}"
|
description: '{% trans "Serial number" %}'
|
||||||
},
|
},
|
||||||
serial_gte: {
|
serial_gte: {
|
||||||
title: "{% trans "Serial number GTE" %}",
|
title: '{% trans "Serial number GTE" %}',
|
||||||
description: "{% trans "Serial number greater than or equal to" %}"
|
description: '{% trans "Serial number greater than or equal to" %}'
|
||||||
},
|
},
|
||||||
serial_lte: {
|
serial_lte: {
|
||||||
title: "{% trans "Serial number LTE" %}",
|
title: '{% trans "Serial number LTE" %}',
|
||||||
description: "{% trans "Serial number less than or equal to" %}",
|
description: '{% trans "Serial number less than or equal to" %}',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
options: stockCodes,
|
options: stockCodes,
|
||||||
@ -154,7 +159,7 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
return {
|
return {
|
||||||
result: {
|
result: {
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: "{% trans 'Test result' %}",
|
title: '{% trans "Test result" %}',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -164,7 +169,7 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
return {
|
return {
|
||||||
required: {
|
required: {
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: "{% trans "Required" %}",
|
title: '{% trans "Required" %}',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -176,9 +181,9 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
title: '{% trans "Build status" %}',
|
title: '{% trans "Build status" %}',
|
||||||
options: buildCodes,
|
options: buildCodes,
|
||||||
},
|
},
|
||||||
pending: {
|
active: {
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Pending" %}',
|
title: '{% trans "Active" %}',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
<div class='modal fade modal-fixed-footer modal-primary' tabindex='-1' role='dialog' id='modal-form'>
|
<div class='modal fade modal-fixed-footer modal-primary' tabindex='-1' role='dialog' id='modal-form'>
|
||||||
<div class='modal-dialog'>
|
<div class='modal-dialog'>
|
||||||
<div class='modal-content'>
|
<div class='modal-content'>
|
||||||
@ -5,13 +7,16 @@
|
|||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
</button>
|
</button>
|
||||||
<h3 id='modal-title'>Form Title Here</h3>
|
<h3 id='modal-title'><i>Form Title Here</i></h3>
|
||||||
|
</div>
|
||||||
|
<div class='alert alert-block alert-danger' id='form-validation-warning' style='display: none;'>
|
||||||
|
{% trans "Form errors exist" %}
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-form-content'>
|
<div class='modal-form-content'>
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-footer'>
|
<div class='modal-footer'>
|
||||||
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>Close</button>
|
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||||
<button type='button' class='btn btn-primary' id='modal-form-submit'>Submit</button>
|
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -24,13 +29,16 @@
|
|||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
</button>
|
</button>
|
||||||
<h3 id='modal-title'>Form Title Here</h3>
|
<h3 id='modal-title'><i>Form Title Here</i></h3>
|
||||||
|
</div>
|
||||||
|
<div class='alert alert-block alert-danger' id='form-validation-warning' style="display: none;">
|
||||||
|
{% trans "Form errors exist" %}
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-form-content'>
|
<div class='modal-form-content'>
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-footer'>
|
<div class='modal-footer'>
|
||||||
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>Close</button>
|
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||||
<button type='button' class='btn btn-primary' id='modal-form-submit'>Submit</button>
|
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -18,14 +18,14 @@
|
|||||||
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
{% if roles.stock.change %}
|
{% if roles.stock.change %}
|
||||||
<li><a href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'>{% trans "Add stock" %}</a></li>
|
<li><a href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'><span class='fas fa-plus-circle'></span> {% trans "Add stock" %}</a></li>
|
||||||
<li><a href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'>{% trans "Remove stock" %}</a></li>
|
<li><a href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
|
||||||
<li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'>{% trans "Count stock" %}</a></li>
|
<li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
|
||||||
<li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'>{% trans "Move stock" %}</a></li>
|
<li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
|
||||||
<li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'>{% trans "Order stock" %}</a></li>
|
<li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if roles.stock.delete %}
|
{% if roles.stock.delete %}
|
||||||
<li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'>{% trans "Delete Stock" %}</a></li>
|
<li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'><span class='fas fa-trash-alt'></span> {% trans "Delete Stock" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,12 +3,13 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin, messages
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from users.models import RuleSet
|
from users.models import RuleSet
|
||||||
|
|
||||||
@ -94,15 +95,37 @@ class RoleGroupAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
filter_horizontal = ['permissions']
|
filter_horizontal = ['permissions']
|
||||||
|
|
||||||
# Save inlines before model
|
|
||||||
# https://stackoverflow.com/a/14860703/12794913
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
pass # don't actually save the parent instance
|
"""
|
||||||
|
This method serves two purposes:
|
||||||
|
- show warning message whenever the group users belong to multiple groups
|
||||||
|
- skip saving of the group instance model as inlines needs to be saved before.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get form cleaned data
|
||||||
|
users = form.cleaned_data['users']
|
||||||
|
|
||||||
|
# Check for users who are members of multiple groups
|
||||||
|
warning_message = ''
|
||||||
|
for user in users:
|
||||||
|
if user.groups.all().count() > 1:
|
||||||
|
warning_message += f'<br>- <b>{user.username}</b> is member of: '
|
||||||
|
for idx, group in enumerate(user.groups.all()):
|
||||||
|
warning_message += f'<b>{group.name}</b>'
|
||||||
|
if idx < len(user.groups.all()) - 1:
|
||||||
|
warning_message += ', '
|
||||||
|
|
||||||
|
# If any, display warning message when group is saved
|
||||||
|
if warning_message:
|
||||||
|
warning_message = mark_safe(_(f'The following users are members of multiple groups:'
|
||||||
|
f'{warning_message}'))
|
||||||
|
messages.add_message(request, messages.WARNING, warning_message)
|
||||||
|
|
||||||
def save_formset(self, request, form, formset, change):
|
def save_formset(self, request, form, formset, change):
|
||||||
formset.save() # this will save the children
|
# Save inline Rulesets
|
||||||
# update_fields is required to trigger permissions update
|
formset.save()
|
||||||
form.instance.save(update_fields=['name']) # form.instance is the parent
|
# Save Group instance and update permissions
|
||||||
|
form.instance.save(update_fields=['name'])
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeUserAdmin(UserAdmin):
|
class InvenTreeUserAdmin(UserAdmin):
|
||||||
|
@ -57,6 +57,7 @@ class RuleSet(models.Model):
|
|||||||
'part_parttesttemplate',
|
'part_parttesttemplate',
|
||||||
'part_partparametertemplate',
|
'part_partparametertemplate',
|
||||||
'part_partparameter',
|
'part_partparameter',
|
||||||
|
'part_partrelated',
|
||||||
'part_partcategoryparametertemplate',
|
'part_partcategoryparametertemplate',
|
||||||
],
|
],
|
||||||
'stock': [
|
'stock': [
|
||||||
@ -72,6 +73,7 @@ class RuleSet(models.Model):
|
|||||||
'part_bomitem',
|
'part_bomitem',
|
||||||
'build_build',
|
'build_build',
|
||||||
'build_builditem',
|
'build_builditem',
|
||||||
|
'build_buildorderattachment',
|
||||||
'stock_stockitem',
|
'stock_stockitem',
|
||||||
'stock_stocklocation',
|
'stock_stocklocation',
|
||||||
],
|
],
|
||||||
|
3
tasks.py
3
tasks.py
@ -171,7 +171,8 @@ def translate(c):
|
|||||||
or after adding translations for existing strings.
|
or after adding translations for existing strings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
manage(c, "makemessages -e html -e py -e js")
|
# Translate applicable .py / .html / .js files
|
||||||
|
manage(c, "makemessages -e py -e html -e js")
|
||||||
manage(c, "compilemessages")
|
manage(c, "compilemessages")
|
||||||
|
|
||||||
@task
|
@task
|
||||||
|
Loading…
Reference in New Issue
Block a user