Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-11-03 22:59:39 +11:00
commit a8f77e7b71
120 changed files with 9283 additions and 4819 deletions

View File

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

View File

@ -78,7 +78,15 @@ function getImageUrlFromTransfer(transfer) {
return url; return url;
} }
function makeIconButton(icon, cls, pk, title) { function makeIconBadge(icon, title) {
// Construct an 'icon badge' which floats to the right of an object
var html = `<span class='fas ${icon} label-right' title='${title}'></span>`;
return html;
}
function makeIconButton(icon, cls, pk, title, options={}) {
// Construct an 'icon button' using the fontawesome set // Construct an 'icon button' using the fontawesome set
var classes = `btn btn-default btn-glyph ${cls}`; var classes = `btn btn-default btn-glyph ${cls}`;
@ -86,15 +94,21 @@ function makeIconButton(icon, cls, pk, title) {
var id = `${cls}-${pk}`; var id = `${cls}-${pk}`;
var html = ''; var html = '';
var extraProps = '';
if (options.disabled) {
extraProps += "disabled='true' ";
}
html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}'>`; html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`;
html += `<span class='fas ${icon}'></span>`; html += `<span class='fas ${icon}'></span>`;
html += `</button>`; html += `</button>`;
return html; return html;
} }
function makeProgressBar(value, maximum, opts) { function makeProgressBar(value, maximum, opts={}) {
/* /*
* Render a progessbar! * Render a progessbar!
* *
@ -120,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';

View File

@ -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.
@ -467,6 +493,9 @@ function openModal(options) {
var modal = options.modal || '#modal-form'; var modal = options.modal || '#modal-form';
// Ensure that the 'warning' div is hidden
$(modal).find('#form-validation-warning').css('display', 'none');
$(modal).on('shown.bs.modal', function() { $(modal).on('shown.bs.modal', function() {
$(modal + ' .modal-form-content').scrollTop(0); $(modal + ' .modal-form-content').scrollTop(0);
if (options.focus) { if (options.focus) {
@ -693,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);
@ -810,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"

View File

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

View File

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

View File

@ -89,14 +89,14 @@ settings_urls = [
# Some javascript files are served 'dynamically', allowing them to pass through the Django translation layer # Some javascript files are served 'dynamically', allowing them to pass through the Django translation layer
dynamic_javascript_urls = [ dynamic_javascript_urls = [
url(r'^barcode.js', DynamicJsView.as_view(template_name='js/barcode.html'), name='barcode.js'), url(r'^barcode.js', DynamicJsView.as_view(template_name='js/barcode.js'), name='barcode.js'),
url(r'^part.js', DynamicJsView.as_view(template_name='js/part.html'), name='part.js'), url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.js'), name='bom.js'),
url(r'^stock.js', DynamicJsView.as_view(template_name='js/stock.html'), name='stock.js'), url(r'^build.js', DynamicJsView.as_view(template_name='js/build.js'), name='build.js'),
url(r'^build.js', DynamicJsView.as_view(template_name='js/build.html'), name='build.js'), url(r'^company.js', DynamicJsView.as_view(template_name='js/company.js'), name='company.js'),
url(r'^order.js', DynamicJsView.as_view(template_name='js/order.html'), name='order.js'), url(r'^order.js', DynamicJsView.as_view(template_name='js/order.js'), name='order.js'),
url(r'^company.js', DynamicJsView.as_view(template_name='js/company.html'), name='company.js'), url(r'^part.js', DynamicJsView.as_view(template_name='js/part.js'), name='part.js'),
url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.html'), name='bom.js'), url(r'^stock.js', DynamicJsView.as_view(template_name='js/stock.js'), name='stock.js'),
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.html'), name='table_filters.js'), url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.js'), name='table_filters.js'),
] ]
urlpatterns = [ urlpatterns = [

View File

@ -213,6 +213,19 @@ class AjaxMixin(InvenTreeRoleMixin):
""" """
return {} return {}
def validate(self, obj, form, **kwargs):
"""
Hook for performing custom form validation steps.
If a form error is detected, add it to the form,
with 'form.add_error()'
Ref: https://docs.djangoproject.com/en/dev/topics/forms/
"""
# Do nothing by default
pass
def renderJsonResponse(self, request, form=None, data={}, context=None): def renderJsonResponse(self, request, form=None, data={}, context=None):
""" Render a JSON response based on specific class context. """ Render a JSON response based on specific class context.
@ -320,18 +333,6 @@ class AjaxCreateView(AjaxMixin, CreateView):
- Handles form validation via AJAX POST requests - Handles form validation via AJAX POST requests
""" """
def pre_save(self, **kwargs):
"""
Hook for doing something before the form is validated
"""
pass
def post_save(self, **kwargs):
"""
Hook for doing something with the created object after it is saved
"""
pass
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
""" Creates form with initial data, and renders JSON response """ """ Creates form with initial data, and renders JSON response """
@ -341,6 +342,17 @@ class AjaxCreateView(AjaxMixin, CreateView):
form = self.get_form() form = self.get_form()
return self.renderJsonResponse(request, form) return self.renderJsonResponse(request, form)
def save(self, form):
"""
Method for actually saving the form to the database.
Default implementation is very simple,
but can be overridden if required.
"""
self.object = form.save()
return self.object
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" Responds to form POST. Validates POST data and returns status info. """ Responds to form POST. Validates POST data and returns status info.
@ -351,16 +363,29 @@ class AjaxCreateView(AjaxMixin, CreateView):
self.request = request self.request = request
self.form = self.get_form() self.form = self.get_form()
# Perform initial form validation
self.form.is_valid()
# Perform custom validation (no object can be provided yet)
self.validate(None, self.form)
valid = self.form.is_valid()
# Extra JSON data sent alongside form # Extra JSON data sent alongside form
data = { data = {
'form_valid': self.form.is_valid(), 'form_valid': valid,
'form_errors': self.form.errors.as_json(),
'non_field_errors': self.form.non_field_errors().as_json(),
} }
if self.form.is_valid(): # Add in any extra class data
for value, key in enumerate(self.get_data()):
data[key] = value
self.pre_save() if valid:
self.object = self.form.save()
self.post_save() # Save the object to the database
self.object = self.save(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
@ -391,6 +416,20 @@ 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 save(self, object, form, **kwargs):
"""
Method for updating the object in the database.
Default implementation is very simple, but can be overridden if required.
Args:
object - The current object, to be updated
form - The validated form
"""
self.object = form.save()
return self.object
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" Respond to POST request. """ Respond to POST request.
@ -400,37 +439,48 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
- Otherwise, return sucess status - Otherwise, return sucess status
""" """
self.request = request
# Make sure we have an object to point to # Make sure we have an object to point to
self.object = self.get_object() self.object = self.get_object()
form = self.get_form() form = self.get_form()
# Perform initial form validation
form.is_valid()
# Perform custom validation
self.validate(self.object, form)
valid = form.is_valid()
data = { data = {
'form_valid': form.is_valid() 'form_valid': valid,
'form_errors': form.errors.as_json(),
'non_field_errors': form.non_field_errors().as_json(),
} }
if form.is_valid(): # Add in any extra class data
obj = form.save() for value, key in enumerate(self.get_data()):
data[key] = value
if valid:
# Save the updated objec to the database
self.save(self.object, form)
self.object = self.get_object()
# Include context data about the updated object # Include context data about the updated object
data['pk'] = obj.id data['pk'] = self.object.pk
self.post_save(obj)
try: try:
data['url'] = obj.get_absolute_url() data['url'] = self.object.get_absolute_url()
except AttributeError: except AttributeError:
pass pass
return self.renderJsonResponse(request, form, data) return self.renderJsonResponse(request, form, data)
def post_save(self, obj, *args, **kwargs):
"""
Hook called after the form data is saved.
(Optional)
"""
pass
class AjaxDeleteView(AjaxMixin, UpdateView): class AjaxDeleteView(AjaxMixin, UpdateView):
@ -440,7 +490,7 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
""" """
form_class = DeleteForm form_class = DeleteForm
ajax_form_title = "Delete Item" ajax_form_title = _("Delete Item")
ajax_template_name = "modal_delete_form.html" ajax_template_name = "modal_delete_form.html"
context_object_name = 'item' context_object_name = 'item'
@ -489,7 +539,7 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
if confirmed: if confirmed:
obj.delete() obj.delete()
else: else:
form.errors['confirm_delete'] = ['Check box to confirm item deletion'] form.add_error('confirm_delete', _('Check box to confirm item deletion'))
context[self.context_object_name] = self.get_object() context[self.context_object_name] = self.get_object()
data = { data = {
@ -504,7 +554,7 @@ class EditUserView(AjaxUpdateView):
""" View for editing user information """ """ View for editing user information """
ajax_template_name = "modal_form.html" ajax_template_name = "modal_form.html"
ajax_form_title = "Edit User Information" ajax_form_title = _("Edit User Information")
form_class = EditUserForm form_class = EditUserForm
def get_object(self): def get_object(self):
@ -515,7 +565,7 @@ class SetPasswordView(AjaxUpdateView):
""" View for setting user password """ """ View for setting user password """
ajax_template_name = "InvenTree/password.html" ajax_template_name = "InvenTree/password.html"
ajax_form_title = "Set Password" ajax_form_title = _("Set Password")
form_class = SetPasswordForm form_class = SetPasswordForm
def get_object(self): def get_object(self):
@ -534,9 +584,9 @@ class SetPasswordView(AjaxUpdateView):
# Passwords must match # Passwords must match
if not p1 == p2: if not p1 == p2:
error = 'Password fields must match' error = _('Password fields must match')
form.errors['enter_password'] = [error] form.add_error('enter_password', error)
form.errors['confirm_password'] = [error] form.add_error('confirm_password', error)
valid = False valid = False

View File

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

View File

@ -31,4 +31,52 @@
level: 0 level: 0
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

View File

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

View File

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

View 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,
},
),
]

View File

@ -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)
@ -62,23 +64,7 @@ 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,
- If there is a single StockItem, use that StockItem for the part to be auto-allocated:
- Take as many parts as available (up to the quantity required for the BOM)
- If there are multiple StockItems available, ignore (leave up to the user)
- The sub_item in the BOM line must *not* be trackable
- There is only a single stock item available (which has not already been allocated to this build)
- 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)
# Skip any parts which are already fully allocated
# Filter by part reference if self.isPartFullyAllocated(part, output):
stock = stock.filter(part=item.sub_part) 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.
Args:
output: Specify which build output to delete allocations (optional)
BuildItem.objects.filter(build=self.id).delete() """
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:
@ -510,28 +869,38 @@ class BuildItem(models.Model):
- StockItem.part must be in the BOM of the Part object referenced by Build - StockItem.part must be in the BOM of the Part object referenced by Build
- Allocation quantity cannot exceed available quantity - Allocation quantity cannot exceed available quantity
""" """
self.validate_unique()
super(BuildItem, self).clean() 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
# Split the allocated stock if there are more available than allocated # For a trackable part, special consideration needed!
if item.quantity > self.quantity: if item.part.trackable:
item = item.splitStock(self.quantity, None, user) # Split the allocated stock if there are more available than allocated
if item.quantity > self.quantity:
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,
}
)

View File

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

View File

@ -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> </div>
{% else %}
<table class='table table-striped table-condensed' id='build-item-list' data-toolbar='#build-item-toolbar'></table> <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>
<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; {
} success: function(response) {
loadBuildOutputAllocationTable(buildInfo, response);
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',
label: '{% trans "New Stock Item" %}',
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);
}
},
},
);
{% 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 %} }
], );
}); {% endfor %}
{% 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,
} }
); );
}); });

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -10,77 +10,120 @@
<hr> <hr>
<table class='table table-striped'> <div class='row'>
<col width='25'> <div class='col-sm-6'>
<tr> <table class='table table-striped'>
<td></td> <col width='25'>
<td>{% trans "Title" %}</td> <tr>
<td>{{ build.title }}</td> <td><span class='fas fa-info'></span></td>
</tr> <td>{% trans "Description" %}</td>
<tr> <td>{{ build.title }}</td>
<td><span class='fas fa-shapes'></span></td> </tr>
<td>{% trans "Part" %}</td> <tr>
<td><a href="{% url 'part-build' build.part.id %}">{{ build.part.full_name }}</a></td> <td><span class='fas fa-shapes'></span></td>
</tr> <td>{% trans "Part" %}</td>
<tr> <td><a href="{% url 'part-build' build.part.id %}">{{ build.part.full_name }}</a></td>
<td></td> </tr>
<td>{% trans "Quantity" %}</td><td>{{ build.quantity }}</td> <tr>
</tr> <td></td>
<tr> <td>{% trans "Quantity" %}</td><td>{{ build.quantity }}</td>
<td><span class='fas fa-map-marker-alt'></span></td> </tr>
<td>{% trans "Stock Source" %}</td> <tr>
<td> <td><span class='fas fa-map-marker-alt'></span></td>
{% if build.take_from %} <td>{% trans "Stock Source" %}</td>
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a> <td>
{% else %} {% if build.take_from %}
{% trans "Stock can be taken from any available location." %} <a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>
{% else %}
<i>{% trans "Stock can be taken from any available location." %}</i>
{% endif %}
</td>
</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>{% trans "Status" %}</td>
<td>{% build_status_label build.status %}</td>
</tr>
<tr>
<td><span class='fas fa-spinner'></span></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>{{ build.batch }}</td>
</tr>
{% endif %} {% endif %}
</td> {% if build.parent %}
</tr> <tr>
<tr> <td><span class='fas fa-sitemap'></span></td>
<td><span class='fas fa-info'></span></td> <td>{% trans "Parent Build" %}</td>
<td>{% trans "Status" %}</td> <td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a></td>
<td>{% build_status_label build.status %}</td> </tr>
</tr>
{% if build.batch %}
<tr>
<td></td>
<td>{% trans "Batch" %}</td>
<td>{{ build.batch }}</td>
</tr>
{% endif %}
{% if build.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td>
<td><a href="{{ build.link }}">{{ build.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ build.creation_date }}</td>
</tr>
{% if build.is_active %}
<tr>
<td></td>
<td>{% trans "Enough Parts?" %}</td>
<td>
{% if build.can_build %}
{% trans "Yes" %}
{% else %}
{% trans "No" %}
{% endif %} {% endif %}
</td> {% if build.sales_order %}
</tr> <tr>
{% endif %} <td><span class='fas fa-dolly'></span></td>
{% if build.completion_date %} <td>{% trans "Sales Order" %}</td>
<tr> <td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td>
<td><span class='fas fa-calendar-alt'></span></td> </tr>
<td>{% trans "Completed" %}</td> {% endif %}
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td> {% if build.link %}
</tr> <tr>
{% endif %} <td><span class='fas fa-link'></span></td>
</table> <td>{% trans "External Link" %}</td>
<td><a href="{{ build.link }}">{{ build.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ build.creation_date }}</td>
</tr>
</table>
</div>
<div class='col-sm-6'>
<table class='table table-striped'>
<col width='25'>
<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>
{% if build.completion_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Completed" %}</td>
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
</tr>
{% endif %}
</table>
</div>
</div>
{% endblock %} {% endblock %}

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

View File

@ -21,7 +21,8 @@ InvenTree | {% trans "Build Orders" %}
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<button type='button' class="btn btn-success" id='new-build'>{% trans "New Build Order" %}</button> <button type='button' class="btn btn-success" id='new-build'>
<span class='fas fa-tools'></span> {% trans "New Build Order" %}</button>
<div class='filter-list' id='filter-list-build'> <div class='filter-list' id='filter-list-build'>
<!-- An empty div in which the filter list will be constructed --> <!-- An empty div in which the filter list will be constructed -->
</div> </div>
@ -40,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"), {

View File

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

View File

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

View File

@ -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,105 +144,145 @@ 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
BuildItem.objects.create( if q11 > 0:
build=self.build, BuildItem.objects.create(
stock_item=self.stock_1_1, build=self.build,
quantity=q11 stock_item=self.stock_1_1,
) quantity=q11,
install_into=output
)
BuildItem.objects.create( if q12 > 0:
build=self.build, BuildItem.objects.create(
stock_item=self.stock_1_2, build=self.build,
quantity=q12 stock_item=self.stock_1_2,
) quantity=q12,
install_into=output
)
BuildItem.objects.create( if q21 > 0:
build=self.build, BuildItem.objects.create(
stock_item=self.stock_2_1, build=self.build,
quantity=q21 stock_item=self.stock_2_1,
) quantity=q21,
install_into=output,
)
with transaction.atomic(): # Attempt to create another identical BuildItem
with self.assertRaises(IntegrityError): b = BuildItem(
BuildItem.objects.create( build=self.build,
build=self.build, stock_item=self.stock_2_1,
stock_item=self.stock_2_1, quantity=q21
quantity=99 )
)
self.assertEqual(BuildItem.objects.count(), 3) with self.assertRaises(ValidationError):
b.clean()
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.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_2))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
self.build.unallocateStock() # 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)
# the original BuildItem objects should have been deleted! # the original BuildItem objects should have been deleted!
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)

View File

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

View File

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

View File

@ -131,6 +131,13 @@ class SupplierPartList(generics.ListCreateAPIView):
if part is not None: if part is not None:
queryset = queryset.filter(part=part) queryset = queryset.filter(part=part)
# Filter by 'active' status of the part?
active = params.get('active', None)
if active is not None:
active = str2bool(active)
queryset = queryset.filter(part__active=active)
return queryset return queryset
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):

View File

@ -15,7 +15,7 @@
</div> </div>
</div> </div>
<table class='table table-striped table-condensed' id='stock-table'></table> <table class='table table-striped table-condensed' id='stock-table' data-toolbar='#button-toolbar'></table>
{% endblock %} {% endblock %}

View File

@ -10,21 +10,33 @@
<hr> <hr>
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
<div id='button-toolbar' class='btn-group'> <div id='button-toolbar'>
{% if roles.purchase_order.add %} <div class='button-toolbar container-fluid'>
<button class="btn btn-success" id='part-create' title='{% trans "Create new supplier part" %}'>{% trans "New Supplier Part" %}</button> <div class='btn-group role='group'>
{% endif %}
<div class="dropdown" style="float: right;">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span></button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li> <button class="btn btn-success" id='part-create' title='{% trans "Create new supplier part" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
</button>
{% endif %} {% endif %}
{% if roles.purchase_order.delete %} <div class='btn-group'>
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li> <div class="dropdown" style="float: right;">
{% endif %} <button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
</ul> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
</div>
</div>
<div class='filter-list' id='filter-list-supplier-part'>
<!-- Empty div (will be filled out with available BOM filters) -->
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -12,7 +12,8 @@
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<div id='button-bar'> <div id='button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='company-order2' title='{% trans "Create new purchase order" %}'>{% trans "New Purchase Order" %}</button> <button class='btn btn-primary' type='button' id='company-order2' title='{% trans "Create new purchase order" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}</button>
<div class='filter-list' id='filter-list-purchaseorder'> <div class='filter-list' id='filter-list-purchaseorder'>
<!-- Empty div --> <!-- Empty div -->
</div> </div>

View File

@ -12,7 +12,9 @@
{% if roles.sales_order.add %} {% if roles.sales_order.add %}
<div id='button-bar'> <div id='button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='new-sales-order' title='{% trans "Create new sales order" %}'>{% trans "New Sales Order" %}</button> <button class='btn btn-primary' type='button' id='new-sales-order' title='{% trans "Create new sales order" %}'>
<div class='fas fa-plus-circle'></div> {% trans "New Sales Order" %}
</button>
<div class='filter-list' id='filter-list-salesorder'> <div class='filter-list' id='filter-list-salesorder'>
<!-- Empty div --> <!-- Empty div -->
</div> </div>

View File

@ -13,7 +13,8 @@
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<div id='button-bar'> <div id='button-bar'>
<div class='btn-group'> <div class='btn-group'>
<button class='btn btn-primary' type='button' id='order-part2' title='Order part'>Order Part</button> <button class='btn btn-primary' type='button' id='order-part2' title='Order part'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Part" %}</button>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -13,7 +13,9 @@
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<div id='price-break-toolbar' class='btn-group'> <div id='price-break-toolbar' class='btn-group'>
<button class='btn btn-primary' id='new-price-break' type='button'>{% trans "Add Price Break" %}</button> <button class='btn btn-primary' id='new-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %}
</button>
</div> </div>
{% endif %} {% endif %}

View File

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

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

View File

@ -21,7 +21,7 @@ from .models import SalesOrderAllocation
class IssuePurchaseOrderForm(HelperForm): class IssuePurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Place order')) confirm = forms.BooleanField(required=True, initial=False, help_text=_('Place order'))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -32,7 +32,7 @@ class IssuePurchaseOrderForm(HelperForm):
class CompletePurchaseOrderForm(HelperForm): class CompletePurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_("Mark order as complete")) confirm = forms.BooleanField(required=True, help_text=_("Mark order as complete"))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -43,7 +43,7 @@ class CompletePurchaseOrderForm(HelperForm):
class CancelPurchaseOrderForm(HelperForm): class CancelPurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Cancel order')) confirm = forms.BooleanField(required=True, help_text=_('Cancel order'))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -54,7 +54,7 @@ class CancelPurchaseOrderForm(HelperForm):
class CancelSalesOrderForm(HelperForm): class CancelSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Cancel order')) confirm = forms.BooleanField(required=True, help_text=_('Cancel order'))
class Meta: class Meta:
model = SalesOrder model = SalesOrder
@ -65,7 +65,7 @@ class CancelSalesOrderForm(HelperForm):
class ShipSalesOrderForm(HelperForm): class ShipSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Ship order')) confirm = forms.BooleanField(required=True, help_text=_('Ship order'))
class Meta: class Meta:
model = SalesOrder model = SalesOrder

View File

@ -209,6 +209,7 @@ class PurchaseOrder(Order):
line.save() line.save()
@transaction.atomic
def place_order(self): def place_order(self):
""" Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """ """ Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """
@ -217,6 +218,7 @@ class PurchaseOrder(Order):
self.issue_date = datetime.now().date() self.issue_date = datetime.now().date()
self.save() self.save()
@transaction.atomic
def complete_order(self): def complete_order(self):
""" Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """ """ Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """
@ -225,10 +227,16 @@ class PurchaseOrder(Order):
self.complete_date = datetime.now().date() self.complete_date = datetime.now().date()
self.save() self.save()
def can_cancel(self):
return self.status not in [
PurchaseOrderStatus.PLACED,
PurchaseOrderStatus.PENDING
]
def cancel_order(self): def cancel_order(self):
""" Marks the PurchaseOrder as CANCELLED. """ """ Marks the PurchaseOrder as CANCELLED. """
if self.status in [PurchaseOrderStatus.PLACED, PurchaseOrderStatus.PENDING]: if self.can_cancel():
self.status = PurchaseOrderStatus.CANCELLED self.status = PurchaseOrderStatus.CANCELLED
self.save() self.save()
@ -377,6 +385,16 @@ class SalesOrder(Order):
return True return True
def can_cancel(self):
"""
Return True if this order can be cancelled
"""
if not self.status == SalesOrderStatus.PENDING:
return False
return True
@transaction.atomic @transaction.atomic
def cancel_order(self): def cancel_order(self):
""" """
@ -386,7 +404,7 @@ class SalesOrder(Order):
- Delete any StockItems which have been allocated - Delete any StockItems which have been allocated
""" """
if not self.status == SalesOrderStatus.PENDING: if not self.can_cancel():
return False return False
self.status = SalesOrderStatus.CANCELLED self.status = SalesOrderStatus.CANCELLED

View File

@ -13,7 +13,8 @@
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'> <div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %} {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
<button type='button' class='btn btn-primary' id='new-po-line'>{% trans "Add Line Item" %}</button> <button type='button' class='btn btn-primary' id='new-po-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}</button>
{% endif %} {% endif %}
</div> </div>

View File

@ -15,7 +15,8 @@ InvenTree | {% trans "Purchase Orders" %}
<div id='table-buttons'> <div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>{% trans "New Purchase Order" %}</button> <button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}</button>
{% endif %} {% endif %}
<div class='filter-list' id='filter-list-purchaseorder'> <div class='filter-list' id='filter-list-purchaseorder'>
<!-- An empty div in which the filter list will be constructed --> <!-- An empty div in which the filter list will be constructed -->

View File

@ -15,7 +15,9 @@
{% if roles.sales_order.change %} {% if roles.sales_order.change %}
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'> <div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
<button type='button' class='btn btn-default' id='new-so-line'>{% trans "Add Line Item" %}</button> <button type='button' class='btn btn-success' id='new-so-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button>
</div> </div>
{% endif %} {% endif %}

View File

@ -15,7 +15,8 @@ InvenTree | {% trans "Sales Orders" %}
<div id='table-buttons'> <div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
{% if roles.sales_order.add %} {% if roles.sales_order.add %}
<button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'>{% trans "New Sales Order" %}</button> <button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Sales Order" %}</button>
{% endif %} {% endif %}
<div class='filter-list' id='filter-list-salesorder'> <div class='filter-list' id='filter-list-salesorder'>
<!-- An empty div in which the filter list will be constructed --> <!-- An empty div in which the filter list will be constructed -->

View File

@ -113,6 +113,7 @@ class POTests(OrderViewTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = json.loads(response.content) data = json.loads(response.content)
self.assertFalse(data['form_valid']) self.assertFalse(data['form_valid'])
# Test WITH confirmation # Test WITH confirmation
@ -151,7 +152,6 @@ class POTests(OrderViewTestCase):
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = json.loads(response.content) data = json.loads(response.content)
self.assertFalse(data['form_valid']) self.assertFalse(data['form_valid'])
self.assertIn('Invalid Purchase Order', str(data['html_form']))
# POST with a part that does not match the purchase order # POST with a part that does not match the purchase order
post_data['order'] = 1 post_data['order'] = 1
@ -159,14 +159,12 @@ class POTests(OrderViewTestCase):
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = json.loads(response.content) data = json.loads(response.content)
self.assertFalse(data['form_valid']) self.assertFalse(data['form_valid'])
self.assertIn('must match for Part and Order', str(data['html_form']))
# POST with an invalid part # POST with an invalid part
post_data['part'] = 12345 post_data['part'] = 12345
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = json.loads(response.content) data = json.loads(response.content)
self.assertFalse(data['form_valid']) self.assertFalse(data['form_valid'])
self.assertIn('Invalid SupplierPart selection', str(data['html_form']))
# POST the form with valid data # POST the form with valid data
post_data['part'] = 100 post_data['part'] = 100

View File

@ -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,9 +100,11 @@ 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, **kwargs): def save(self, form, **kwargs):
self.object.user = self.request.user
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 {
@ -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, **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,11 +328,14 @@ class PurchaseOrderCreate(AjaxCreateView):
return initials return initials
def post_save(self, **kwargs): def save(self, form, **kwargs):
# Record the user who created this purchase order """
Record the user who created this PurchaseOrder
"""
self.object.created_by = self.request.user order = form.save(commit=False)
self.object.save() order.created_by = self.request.user
order.save()
class SalesOrderCreate(AjaxCreateView): class SalesOrderCreate(AjaxCreateView):
@ -351,10 +363,14 @@ class SalesOrderCreate(AjaxCreateView):
return initials return initials
def post_save(self, **kwargs): def save(self, form, **kwargs):
# Record the user who created this sales order """
self.object.created_by = self.request.user Record the user who created this SalesOrder
self.object.save() """
order = form.save(commit=False)
order.created_by = self.request.user
order.save()
class PurchaseOrderEdit(AjaxUpdateView): class PurchaseOrderEdit(AjaxUpdateView):
@ -404,29 +420,22 @@ class PurchaseOrderCancel(AjaxUpdateView):
form_class = order_forms.CancelPurchaseOrderForm form_class = order_forms.CancelPurchaseOrderForm
role_required = 'purchase_order.change' role_required = 'purchase_order.change'
def post(self, request, *args, **kwargs): def validate(self, order, form, **kwargs):
""" Mark the PO as 'CANCELLED' """
confirm = str2bool(form.cleaned_data.get('confirm', False))
order = self.get_object()
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm: if not confirm:
form.errors['confirm'] = [_('Confirm order cancellation')] form.add_error('confirm', _('Confirm order cancellation'))
else:
valid = True
data = { if not order.can_cancel():
'form_valid': valid form.add_error(None, _('Order cannot be cancelled'))
}
if valid: def save(self, order, form, **kwargs):
order.cancel_order() """
Cancel the PurchaseOrder
"""
return self.renderJsonResponse(request, form, data) order.cancel_order()
class SalesOrderCancel(AjaxUpdateView): class SalesOrderCancel(AjaxUpdateView):
@ -438,30 +447,22 @@ class SalesOrderCancel(AjaxUpdateView):
form_class = order_forms.CancelSalesOrderForm form_class = order_forms.CancelSalesOrderForm
role_required = 'sales_order.change' role_required = 'sales_order.change'
def post(self, request, *args, **kwargs): def validate(self, order, form, **kwargs):
order = self.get_object() confirm = str2bool(form.cleaned_data.get('confirm', False))
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm: if not confirm:
form.errors['confirm'] = [_('Confirm order cancellation')] form.add_error('confirm', _('Confirm order cancellation'))
else:
valid = True
if valid: if not order.can_cancel():
if not order.cancel_order(): form.add_error(None, _('Order cannot be cancelled'))
form.non_field_errors = [_('Could not cancel order')]
valid = False
data = { def save(self, order, form, **kwargs):
'form_valid': valid, """
} Once the form has been validated, cancel the SalesOrder
"""
return self.renderJsonResponse(request, form, data) order.cancel_order()
class PurchaseOrderIssue(AjaxUpdateView): class PurchaseOrderIssue(AjaxUpdateView):
@ -473,30 +474,24 @@ class PurchaseOrderIssue(AjaxUpdateView):
form_class = order_forms.IssuePurchaseOrderForm form_class = order_forms.IssuePurchaseOrderForm
role_required = 'purchase_order.change' role_required = 'purchase_order.change'
def post(self, request, *args, **kwargs): def validate(self, order, form, **kwargs):
""" Mark the purchase order as 'PLACED' """
order = self.get_object() confirm = str2bool(self.request.POST.get('confirm', False))
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm: if not confirm:
form.errors['confirm'] = [_('Confirm order placement')] form.add_error('confirm', _('Confirm order placement'))
else:
valid = True
data = { def save(self, order, form, **kwargs):
'form_valid': valid, """
Once the form has been validated, place the order.
"""
order.place_order()
def get_data(self):
return {
'success': _('Purchase order issued')
} }
if valid:
order.place_order()
return self.renderJsonResponse(request, form, data)
class PurchaseOrderComplete(AjaxUpdateView): class PurchaseOrderComplete(AjaxUpdateView):
""" View for marking a PurchaseOrder as complete. """ View for marking a PurchaseOrder as complete.
@ -517,23 +512,25 @@ class PurchaseOrderComplete(AjaxUpdateView):
return ctx return ctx
def post(self, request, *args, **kwargs): def validate(self, order, form, **kwargs):
confirm = str2bool(request.POST.get('confirm', False)) confirm = str2bool(form.cleaned_data.get('confirm', False))
if confirm: if not confirm:
po = self.get_object() form.add_error('confirm', _('Confirm order completion'))
po.status = PurchaseOrderStatus.COMPLETE
po.save()
data = { def save(self, order, form, **kwargs):
'form_valid': confirm """
Complete the PurchaseOrder
"""
order.complete_order()
def get_data(self):
return {
'success': _('Purchase order completed')
} }
form = self.get_form()
return self.renderJsonResponse(request, form, data)
class SalesOrderShip(AjaxUpdateView): class SalesOrderShip(AjaxUpdateView):
""" View for 'shipping' a SalesOrder """ """ View for 'shipping' a SalesOrder """
@ -558,13 +555,13 @@ class SalesOrderShip(AjaxUpdateView):
valid = False valid = False
if not confirm: if not confirm:
form.errors['confirm'] = [_('Confirm order shipment')] form.add_error('confirm', _('Confirm order shipment'))
else: else:
valid = True valid = True
if valid: if valid:
if not order.ship_order(request.user): if not order.ship_order(request.user):
form.non_field_errors = [_('Could not ship order')] form.add_error(None, _('Could not ship order'))
valid = False valid = False
data = { data = {
@ -913,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
@ -1117,52 +1115,21 @@ class POLineItemCreate(AjaxCreateView):
ajax_form_title = _('Add Line Item') ajax_form_title = _('Add Line Item')
role_required = 'purchase_order.add' role_required = 'purchase_order.add'
def post(self, request, *arg, **kwargs): def validate(self, item, form, **kwargs):
self.request = request order = form.cleaned_data.get('order', None)
form = self.get_form() part = form.cleaned_data.get('part', None)
valid = form.is_valid() if not part:
form.add_error('part', _('Supplier part must be specified'))
# Extract the SupplierPart ID from the form if part and order:
part_id = form['part'].value() if not part.supplier == order.supplier:
form.add_error(
# Extract the Order ID from the form 'part',
order_id = form['order'].value() _('Supplier must match for Part and Order')
)
try:
order = PurchaseOrder.objects.get(id=order_id)
except (ValueError, PurchaseOrder.DoesNotExist):
order = None
form.errors['order'] = [_('Invalid Purchase Order')]
valid = False
try:
sp = SupplierPart.objects.get(id=part_id)
if order is not None:
if not sp.supplier == order.supplier:
form.errors['part'] = [_('Supplier must match for Part and Order')]
valid = False
except (SupplierPart.DoesNotExist, ValueError):
valid = False
form.errors['part'] = [_('Invalid SupplierPart selection')]
data = {
'form_valid': valid,
}
if valid:
self.object = form.save()
data['pk'] = self.object.pk
data['text'] = str(self.object)
else:
self.object = None
return self.renderJsonResponse(request, form, data,)
def get_form(self): def get_form(self):
""" Limit choice options based on the selected order, etc """ Limit choice options based on the selected order, etc

View File

@ -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 PartTestTemplate from .models import PartTestTemplate
@ -121,6 +121,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')
@ -279,6 +284,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)

View File

@ -461,8 +461,8 @@ class PartList(generics.ListCreateAPIView):
else: else:
queryset = queryset.exclude(pk__in=starred_parts) queryset = queryset.exclude(pk__in=starred_parts)
# Cascade? # Cascade? (Default = True)
cascade = str2bool(params.get('cascade', None)) cascade = str2bool(params.get('cascade', True))
# Does the user wish to filter by category? # Does the user wish to filter by category?
cat_id = params.get('category', None) cat_id = params.get('category', None)
@ -761,12 +761,44 @@ class BomList(generics.ListCreateAPIView):
if sub_part is not None: if sub_part is not None:
queryset = queryset.filter(sub_part=sub_part) queryset = queryset.filter(sub_part=sub_part)
# Filter by "trackable" status of the sub-part # Filter by "active" status of the part
trackable = params.get('trackable', None) part_active = params.get('part_active', None)
if trackable is not None: if part_active is not None:
trackable = str2bool(trackable) part_active = str2bool(part_active)
queryset = queryset.filter(sub_part__trackable=trackable) queryset = queryset.filter(part__active=part_active)
# Filter by "trackable" status of the part
part_trackable = params.get('part_trackable', None)
if part_trackable is not None:
part_trackable = str2bool(part_trackable)
queryset = queryset.filter(part__trackable=part_trackable)
# Filter by "trackable" status of the sub-part
sub_part_trackable = params.get('sub_part_trackable', None)
if sub_part_trackable is not None:
sub_part_trackable = str2bool(sub_part_trackable)
queryset = queryset.filter(sub_part__trackable=sub_part_trackable)
# Filter by whether the BOM line has been validated
validated = params.get('validated', None)
if validated is not None:
validated = str2bool(validated)
# Work out which lines have actually been validated
pks = []
for bom_item in queryset.all():
if bom_item.is_line_valid:
pks.append(bom_item.pk)
if validated:
queryset = queryset.filter(pk__in=pks)
else:
queryset = queryset.exclude(pk__in=pks)
return queryset return queryset

View File

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

View File

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

View File

@ -13,16 +13,21 @@ 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 PartTestTemplate from .models import PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak
from common.models import Currency from common.models import Currency
class PartModelChoiceField(forms.ModelChoiceField):
""" Extending string representation of Part instance with available stock """
def label_from_instance(self, part):
return f'{part} - {part.available_stock}'
class PartImageForm(HelperForm): class PartImageForm(HelperForm):
""" Form for uploading a Part image """ """ Form for uploading a Part image """
@ -77,6 +82,38 @@ class BomExportForm(forms.Form):
self.fields['file_format'].choices = self.get_choices() self.fields['file_format'].choices = self.get_choices()
class BomDuplicateForm(HelperForm):
"""
Simple confirmation form for BOM duplication.
Select which parent to select from.
"""
parent = PartModelChoiceField(
label=_('Parent Part'),
help_text=_('Select parent part to copy BOM from'),
queryset=Part.objects.filter(is_template=True),
)
clear = forms.BooleanField(
required=False, initial=True,
help_text=_('Clear existing BOM items')
)
confirm = forms.BooleanField(
required=False, initial=False,
help_text=_('Confirm BOM duplication')
)
class Meta:
model = Part
fields = [
'parent',
'clear',
'confirm',
]
class BomValidateForm(HelperForm): class BomValidateForm(HelperForm):
""" Simple confirmation form for BOM validation. """ Simple confirmation form for BOM validation.
User is presented with a single checkbox input, User is presented with a single checkbox input,
@ -104,6 +141,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 """
@ -210,12 +266,6 @@ class EditCategoryForm(HelperForm):
] ]
class PartModelChoiceField(forms.ModelChoiceField):
""" Extending string representation of Part instance with available stock """
def label_from_instance(self, part):
return f'{part} - {part.available_stock}'
class EditBomItemForm(HelperForm): class EditBomItemForm(HelperForm):
""" Form for editing a BomItem object """ """ Form for editing a BomItem object """

View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-10-27 04:57
import InvenTree.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0051_bomitem_optional'),
]
operations = [
migrations.AlterField(
model_name='part',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True),
),
]

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

View 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 = [
]

View File

@ -245,7 +245,7 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False):
if ratio >= threshold: if ratio >= threshold:
matches.append({ matches.append({
'part': part, 'part': part,
'ratio': ratio 'ratio': round(ratio, 1)
}) })
matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse) matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse)
@ -360,7 +360,7 @@ class Part(MPTTModel):
# And recursively check too # And recursively check too
item.sub_part.checkAddToBOM(parent) item.sub_part.checkAddToBOM(parent)
def checkIfSerialNumberExists(self, sn): def checkIfSerialNumberExists(self, sn, exclude_self=False):
""" """
Check if a serial number exists for this Part. Check if a serial number exists for this Part.
@ -369,10 +369,27 @@ class Part(MPTTModel):
""" """
parts = Part.objects.filter(tree_id=self.tree_id) parts = Part.objects.filter(tree_id=self.tree_id)
stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn) stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn)
if exclude_self:
stock = stock.exclude(pk=self.pk)
return stock.exists() return stock.exists()
def find_conflicting_serial_numbers(self, serials):
"""
For a provided list of serials, return a list of those which are conflicting.
"""
conflicts = []
for serial in serials:
if self.checkIfSerialNumberExists(serial, exclude_self=True):
conflicts.append(serial)
return conflicts
def getLatestSerialNumber(self): def getLatestSerialNumber(self):
""" """
Return the "latest" serial number for this Part. Return the "latest" serial number for this Part.
@ -529,10 +546,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]
@ -562,7 +593,7 @@ class Part(MPTTModel):
revision = models.CharField(max_length=100, blank=True, null=True, help_text=_('Part revision or version number')) revision = models.CharField(max_length=100, blank=True, null=True, help_text=_('Part revision or version number'))
link = InvenTreeURLField(blank=True, null=True, help_text=_('Link to extenal URL')) link = InvenTreeURLField(blank=True, null=True, help_text=_('Link to external URL'))
image = StdImageField( image = StdImageField(
upload_to=rename_part_image, upload_to=rename_part_image,
@ -874,6 +905,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 """
@ -931,15 +975,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
@ -1087,7 +1147,61 @@ class Part(MPTTModel):
max(buy_price_range[1], bom_price_range[1]) max(buy_price_range[1], bom_price_range[1])
) )
def deepCopy(self, other, **kwargs): @transaction.atomic
def copy_bom_from(self, other, clear=True, **kwargs):
"""
Copy the BOM from another part.
args:
other - The part to copy the BOM from
clear - Remove existing BOM items first (default=True)
"""
if clear:
# Remove existing BOM items
self.bom_items.all().delete()
for bom_item in other.bom_items.all():
# If this part already has a BomItem pointing to the same sub-part,
# delete that BomItem from this part first!
try:
existing = BomItem.objects.get(part=self, sub_part=bom_item.sub_part)
existing.delete()
except (BomItem.DoesNotExist):
pass
bom_item.part = self
bom_item.pk = None
bom_item.save()
@transaction.atomic
def copy_parameters_from(self, other, **kwargs):
clear = kwargs.get('clear', True)
if clear:
self.get_parameters().delete()
for parameter in other.get_parameters():
# If this part already has a parameter pointing to the same template,
# delete that parameter from this part first!
try:
existing = PartParameter.objects.get(part=self, template=parameter.template)
existing.delete()
except (PartParameter.DoesNotExist):
pass
parameter.part = self
parameter.pk = None
parameter.save()
@transaction.atomic
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.
@ -1106,24 +1220,12 @@ class Part(MPTTModel):
# Copy the BOM data # Copy the BOM data
if kwargs.get('bom', False): if kwargs.get('bom', False):
for item in other.bom_items.all(): self.copy_bom_from(other)
# Point the item to THIS part.
# Set the pk to None so a new entry is created.
item.part = self
item.pk = None
item.save()
# Copy the parameters data # Copy the parameters data
if kwargs.get('parameters', True): if kwargs.get('parameters', True):
# Get template part parameters self.copy_parameters_from(other)
parameters = other.get_parameters()
# Copy template part parameters to new variant part
for parameter in parameters:
PartParameter.create(part=self,
template=parameter.template,
data=parameter.data,
save=True)
# Copy the fields that aren't available in the duplicate form # Copy the fields that aren't available in the duplicate form
self.salable = other.salable self.salable = other.salable
self.assembly = other.assembly self.assembly = other.assembly
@ -1254,6 +1356,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
@ -1596,12 +1724,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
@ -1611,6 +1742,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
@ -1723,3 +1861,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

View File

@ -17,15 +17,15 @@
</tr> </tr>
{% for allocation in part.build_order_allocations %} {% for allocation in part.build_order_allocations %}
<tr> <tr>
<td><a href="{% url 'build-detail' allocation.build.id %}">{% trans "Build Order" %}: {{ allocation.build.pk }}</a></td> <td><a href="{% url 'build-detail' allocation.build.id %}">{% trans "Build Order" %}: {{ allocation.build }}</a></td>
<td><a href="{% url 'stock-item-detail' allocation.stock_item.id %}">{% trans "Stock Item" %}: {{ allocation.stock_item.id }}</a></td> <td><a href="{% url 'stock-item-detail' allocation.stock_item.id %}">{% trans "Stock Item" %}: {{ allocation.stock_item }}</a></td>
<td>{% decimal allocation.quantity %}</td> <td>{% decimal allocation.quantity %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% for allocation in part.sales_order_allocations %} {% for allocation in part.sales_order_allocations %}
<tr> <tr>
<td><a href="{% url 'so-detail' allocation.line.order.id %}">{% trans "Sales Order" %}: {{ allocation.line.order.pk }}</a></td> <td><a href="{% url 'so-detail' allocation.line.order.id %}">{% trans "Sales Order" %}: {{ allocation.line.order }}</a></td>
<td><a href="{% url 'stock-item-detail' allocation.item.id %}">{% trans "Stock Item" %}: {{ allocation.item.id }}</a></td> <td><a href="{% url 'stock-item-detail' allocation.item.id %}">{% trans "Stock Item" %}: {{ allocation.item }}</a></td>
<td>{% decimal allocation.quantity %}</td> <td>{% decimal allocation.quantity %}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -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'>
@ -32,21 +28,45 @@
</div> </div>
{% endif %} {% endif %}
<div id='button-toolbar' class="btn-group" role="group" aria-label="..."> <div id='button-toolbar'>
{% if editing_enabled %} <div class="btn-group" role="group" aria-label="...">
<button class='btn btn-default action-button' type='button' title='{% trans "Remove selected BOM items" %}' id='bom-item-delete'><span class='fas fa-trash-alt'></span></button> {% if editing_enabled %}
<button class='btn btn-default action-button' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'><span class='fas fa-file-upload'></span></button> <button class='btn btn-default' type='button' title='{% trans "Remove selected BOM items" %}' id='bom-item-delete'>
<button class='btn btn-default action-button' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'><span class='fas fa-plus-circle'></span></button> <span class='fas fa-trash-alt'></span>
<button class='btn btn-default action-button' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'><span class='fas fa-check-circle'></span></button> </button>
{% elif part.active %} <button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
{% if roles.part.change %} <span class='fas fa-file-upload'></span> {% trans "Import from File" %}
<button class='btn btn-default action-button' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'><span class='fas fa-edit'></span></button> </button>
{% if part.is_bom_valid == False %} {% if part.variant_of %}
<button class='btn btn-default action-button' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'><span class='fas fa-clipboard-check'></span></button> <button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
{% endif %} <span class='fas fa-clone'></span> {% trans "Copy from Parent" %}
{% endif %} </button>
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default action-button' id='download-bom' type='button'><span class='fas fa-file-download'></span></button> {% endif %}
{% endif %} <button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
<span class='fas fa-plus-circle'></span> {% trans "Add Item" %}
</button>
<button class='btn btn-success' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'>
<span class='fas fa-check-circle'></span> {% trans "Finished" %}
</button>
{% elif part.active %}
{% if roles.part.change %}
<button class='btn btn-primary' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'>
<span class='fas fa-edit'></span> {% trans "Edit" %}
</button>
{% if part.is_bom_valid == False %}
<button class='btn btn-success' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'>
<span class='fas fa-clipboard-check'></span> {% trans "Validate" %}
</button>
{% endif %}
{% endif %}
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default' id='download-bom' type='button'>
<span class='fas fa-file-download'></span> {% trans "Export" %}
</button>
{% endif %}
</div>
<div class='filter-list' id='filter-list-bom'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
</div>
</div> </div>
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='bom-table'> <table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
@ -138,6 +158,17 @@
location.href = "{% url 'upload-bom' part.id %}"; location.href = "{% url 'upload-bom' part.id %}";
}); });
$('#bom-duplicate').click(function() {
launchModalForm(
"{% url 'duplicate-bom' part.id %}",
{
success: function() {
$('#bom-table').bootstrapTable('refresh');
}
}
);
});
$("#bom-item-new").click(function () { $("#bom-item-new").click(function () {
launchModalForm( launchModalForm(
"{% url 'bom-item-create' %}?parent={{ part.id }}", "{% url 'bom-item-create' %}?parent={{ part.id }}",
@ -184,4 +215,4 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<p>
{% trans "Select parent part to copy BOM from" %}
</p>
{% if part.has_bom %}
<div class='alert alert-block alert-danger'>
<b>{% trans "Warning" %}</b><br>
{% trans "This part already has a Bill of Materials" %}<br>
</div>
{% endif %}
{% endblock %}

View File

@ -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,14 +30,11 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#start-build").click(function() { $("#start-build").click(function() {
launchModalForm( newBuildOrder({
"{% url 'build-create' %}", data: {
{ part: {{ part.id }},
follow: true, }
data: { });
part: {{ part.id }}
}
});
}); });
loadBuildTable($("#build-table"), { loadBuildTable($("#build-table"), {

View File

@ -107,22 +107,24 @@
<hr> <hr>
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-fluid' style="float: right;"> <div class='btn-group'>
<button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>
<span class='fas fa-file-download'></span> {% trans "Export" %}
</button>
{% if roles.part.add %}
<button class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Part" %}
</button>
{% endif %}
<div class='btn-group'> <div class='btn-group'>
<button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>{% trans "Export" %}</button> <button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
{% if roles.part.add %} <ul class='dropdown-menu'>
<button class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'>{% trans "New Part" %}</button> {% if roles.part.change %}
{% endif %} <li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
<div class='btn-group'> {% endif %}
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button> <li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
<ul class='dropdown-menu'> <li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
{% if roles.part.change %} </ul>
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
{% endif %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul>
</div>
</div> </div>
</div> </div>
<div class='filter-list' id='filter-list-parts'> <div class='filter-list' id='filter-list-parts'>

View File

@ -1,19 +1,23 @@
{% extends "modal_form.html" %} {% extends "modal_form.html" %}
{% load inventree_extras %}
{% load i18n %}
{% block pre_form_content %} {% block pre_form_content %}
{{ block.super }} {{ block.super }}
{% if matches %} {% if matches %}
<b>Possible Matching Parts</b> <div class='alert alert-block alert-warning'>
<p>The new part may be a duplicate of these existing parts:</p> <b>{% trans "Possible Matching Parts" %}</b>
<ul class='list-group'> <p>{% trans "The new part may be a duplicate of these existing parts" %}:</p>
{% for match in matches %} <ul class='list-group'>
<li class='list-group-item list-group-item-condensed'> {% for match in matches %}
{{ match.part.full_name }} - <i>{{ match.part.description }}</i> ({{ match.ratio }}%) <li class='list-group-item list-group-item-condensed'>
</li> {{ match.part.full_name }} - <i>{{ match.part.description }}</i> ({% decimal match.ratio %}% {% trans "match" %})
{% endfor %} </li>
{% endfor %}
</ul> </ul>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -15,7 +15,7 @@
<table class='table table-striped'> <table class='table table-striped'>
<col width='25'> <col width='25'>
<tr> <tr>
<td></td> <td><span class='fas fa-font'></span></td>
<td><b>{% trans "Part name" %}</b></td> <td><b>{% trans "Part name" %}</b></td>
<td>{{ part.name }}</td> <td>{{ part.name }}</td>
</tr> </tr>
@ -28,7 +28,7 @@
{% endif %} {% endif %}
{% if part.revision %} {% if part.revision %}
<tr> <tr>
<td></td> <td><span class='fas fa-code-branch'></span></td>
<td><b>{% trans "Revision" %}</b></td> <td><b>{% trans "Revision" %}</b></td>
<td>{{ part.revision }}</td> <td>{{ part.revision }}</td>
</tr> </tr>
@ -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>
@ -132,7 +132,9 @@
</div> </div>
<div class='col-sm-6'> <div class='col-sm-6'>
<table class='table table-striped'> <table class='table table-striped'>
<col width='25'>
<tr> <tr>
<td><span class='fas fa-ghost'%></span></td>
<td><b>{% trans "Virtual" %}</b></td> <td><b>{% trans "Virtual" %}</b></td>
<td>{% include "slide.html" with state=part.virtual field='virtual' %}</td> <td>{% include "slide.html" with state=part.virtual field='virtual' %}</td>
{% if part.virtual %} {% if part.virtual %}
@ -142,6 +144,7 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-clone'></span></td>
<td><b>{% trans "Template" %}</b></td> <td><b>{% trans "Template" %}</b></td>
<td>{% include "slide.html" with state=part.is_template field='is_template' %}</td> <td>{% include "slide.html" with state=part.is_template field='is_template' %}</td>
{% if part.is_template %} {% if part.is_template %}
@ -151,6 +154,7 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-tools'></span></td>
<td><b>{% trans "Assembly" %}</b></td> <td><b>{% trans "Assembly" %}</b></td>
<td>{% include "slide.html" with state=part.assembly field='assembly' %}</td> <td>{% include "slide.html" with state=part.assembly field='assembly' %}</td>
{% if part.assembly %} {% if part.assembly %}
@ -160,6 +164,7 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-th'></span></td>
<td><b>{% trans "Component" %}</b></td> <td><b>{% trans "Component" %}</b></td>
<td>{% include "slide.html" with state=part.component field='component' %}</td> <td>{% include "slide.html" with state=part.component field='component' %}</td>
{% if part.component %} {% if part.component %}
@ -169,6 +174,7 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-directions'></span></td>
<td><b>{% trans "Trackable" %}</b></td> <td><b>{% trans "Trackable" %}</b></td>
<td>{% include "slide.html" with state=part.trackable field='trackable' %}</td> <td>{% include "slide.html" with state=part.trackable field='trackable' %}</td>
{% if part.trackable %} {% if part.trackable %}
@ -178,6 +184,7 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-shopping-cart'></span></td>
<td><b>{% trans "Purchaseable" %}</b></td> <td><b>{% trans "Purchaseable" %}</b></td>
<td>{% include "slide.html" with state=part.purchaseable field='purchaseable' %}</td> <td>{% include "slide.html" with state=part.purchaseable field='purchaseable' %}</td>
{% if part.purchaseable %} {% if part.purchaseable %}
@ -187,6 +194,7 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td><b>{% trans "Salable" %}</b></td> <td><b>{% trans "Salable" %}</b></td>
<td>{% include "slide.html" with state=part.salable field='salable' %}</td> <td>{% include "slide.html" with state=part.salable field='salable' %}</td>
{% if part.salable %} {% if part.salable %}
@ -196,6 +204,13 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<td>
{% if part.active %}
<span class='fas fa-check-square'></span>
{% else %}
<span class='fas fa-times-square'></span>
{% endif %}
</td>
<td><b>{% trans "Active" %}</b></td> <td><b>{% trans "Active" %}</b></td>
<td>{% include "slide.html" with state=part.active field='active' disabled=False %}</td> <td>{% include "slide.html" with state=part.active field='active' disabled=False %}</td>
{% if part.active %} {% if part.active %}

View File

@ -11,7 +11,9 @@
<div id='button-bar'> <div id='button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='part-order2' title='{% trans "Order part" %}'>{% trans "Order Part" %}</button> <button class='btn btn-primary' type='button' id='part-order2' title='{% trans "Order part" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Part" %}
</button>
<div class='filter-list' id='filter-list-purchaseorder'> <div class='filter-list' id='filter-list-purchaseorder'>
<!-- An empty div in which the filter list will be constructed --> <!-- An empty div in which the filter list will be constructed -->
</div> </div>

View File

@ -11,7 +11,9 @@
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
{% if roles.part.add %} {% if roles.part.add %}
<button title='{% trans "Add new parameter" %}' class='btn btn-success' id='param-create'>{% trans "New Parameter" %}</button> <button title='{% trans "Add new parameter" %}' class='btn btn-success' id='param-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
{% endif %} {% endif %}
</div> </div>
</div> </div>

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

View File

@ -10,7 +10,9 @@
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='btn-group'> <div class='btn-group'>
<button class="btn btn-success" id='supplier-create'>{% trans "New Supplier Part" %}</button> <button class="btn btn-success" id='supplier-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
</button>
<div id='opt-dropdown' class="btn-group"> <div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button> <button id='supplier-part-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">

View File

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

View File

@ -8,7 +8,13 @@
<hr> <hr>
<table class="table table-striped table-condensed" id='used-table'> <div id='button-toolbar'>
<div class='filter-list' id='filter-list-usedin'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
</div>
</div>
<table class="table table-striped table-condensed" id='used-table' data-toolbar='#button-toolbar'>
</table> </table>
{% endblock %} {% endblock %}
@ -16,52 +22,10 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#used-table").inventreeTable({ loadUsedInTable('#used-table', {
formatNoMatches: function() { return "{{ part.full_name }} is not used to make any other parts"; }, part_detail: true,
queryParams: function(p) { part_id: {{ part.pk }}
return { });
sub_part: {{ part.id }},
part_detail: true,
}
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part_detail',
title: 'Part',
sortable: true,
formatter: function(value, row, index, field) {
var link = `/part/${value.pk}/bom/`;
var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value.full_name, link);
if (!row.part_detail.active) {
html += "<span class='label label-warning' style='float: right;'>{% trans "INACTIVE" %}</span>";
}
return html;
}
},
{
field: 'part_detail.description',
title: 'Description',
sortable: true,
},
{
sortable: true,
field: 'quantity',
title: 'Uses',
formatter: function(value, row, index, field) {
return parseFloat(value);
},
}
],
url: "{% url 'api-bom-list' %}"
})
{% endblock %} {% endblock %}

View File

@ -16,10 +16,17 @@
<hr> <hr>
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='btn-group'> <div class='button-toolbar container-fluid'>
{% if part.is_template and part.active %} <div class='btn-group' role='group'>
<button class='btn btn-success' id='new-variant' title='{% trans "Create new variant" %}'>{% trans "New Variant" %}</button> {% if part.is_template and part.active %}
{% endif %} <button class='btn btn-success' id='new-variant' title='{% trans "Create new variant" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Variant" %}
</button>
{% endif %}
</div>
<div class='filter-list' id='filter-list-variants'>
<!-- Empty div (will be filled out with available BOM filters) -->
</div>
</div> </div>
</div> </div>

View File

@ -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 """
@ -106,6 +119,15 @@ def setting_object(key, *args, **kwargs):
return setting return setting
@register.simple_tag()
def settings_value(key, *args, **kwargs):
"""
Return a settings value specified by the given key
"""
return InvenTreeSetting.get_setting(key)
@register.simple_tag() @register.simple_tag()
def get_color_theme_css(username): def get_color_theme_css(username):
try: try:

View File

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

View File

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

View File

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

View File

@ -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'),
@ -46,6 +51,7 @@ part_detail_urls = [
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'),
url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'), url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'),
url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'), url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'),
@ -60,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'),
@ -112,6 +119,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)),

View File

@ -21,7 +21,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 BomItem from .models import BomItem
from .models import match_part_names from .models import match_part_names
@ -70,6 +70,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
@ -82,10 +161,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 {
@ -360,7 +443,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)
@ -371,6 +454,8 @@ class MakePartVariant(AjaxCreateView):
initials = model_to_dict(part_template) initials = model_to_dict(part_template)
initials['is_template'] = False initials['is_template'] = False
initials['variant_of'] = part_template initials['variant_of'] = part_template
initials['bom_copy'] = InvenTreeSetting.get_setting('PART_COPY_BOM')
initials['parameters_copy'] = InvenTreeSetting.get_setting('PART_COPY_PARAMETERS')
return initials return initials
@ -436,7 +521,8 @@ class PartDuplicate(AjaxCreateView):
matches = match_part_names(name) matches = match_part_names(name)
if len(matches) > 0: if len(matches) > 0:
context['matches'] = matches # Display the first five closest matches
context['matches'] = matches[:5]
# Enforce display of the checkbox # Enforce display of the checkbox
form.fields['confirm_creation'].widget = CheckboxInput() form.fields['confirm_creation'].widget = CheckboxInput()
@ -445,9 +531,9 @@ class PartDuplicate(AjaxCreateView):
confirmed = str2bool(request.POST.get('confirm_creation', False)) confirmed = str2bool(request.POST.get('confirm_creation', False))
if not confirmed: if not confirmed:
form.errors['confirm_creation'] = ['Possible matches exist - confirm creation of new part'] msg = _('Possible matches exist - confirm creation of new part')
form.add_error('confirm_creation', msg)
form.pre_form_warning = 'Possible matches exist - confirm creation of new part' form.pre_form_warning = msg
valid = False valid = False
data = { data = {
@ -470,7 +556,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()
@ -510,7 +596,7 @@ class PartCreate(AjaxCreateView):
model = Part model = Part
form_class = part_forms.EditPartForm form_class = part_forms.EditPartForm
ajax_form_title = _('Create new part') ajax_form_title = _('Create New Part')
ajax_template_name = 'part/create_part.html' ajax_template_name = 'part/create_part.html'
role_required = 'part.add' role_required = 'part.add'
@ -575,9 +661,10 @@ class PartCreate(AjaxCreateView):
confirmed = str2bool(request.POST.get('confirm_creation', False)) confirmed = str2bool(request.POST.get('confirm_creation', False))
if not confirmed: if not confirmed:
form.errors['confirm_creation'] = ['Possible matches exist - confirm creation of new part'] msg = _('Possible matches exist - confirm creation of new part')
form.add_error('confirm_creation', msg)
form.pre_form_warning = 'Possible matches exist - confirm creation of new part' form.pre_form_warning = msg
valid = False valid = False
data = { data = {
@ -830,8 +917,63 @@ class PartEdit(AjaxUpdateView):
return form return form
class BomDuplicate(AjaxUpdateView):
"""
View for duplicating BOM from a parent item.
"""
model = Part
context_object_name = 'part'
ajax_form_title = _('Duplicate BOM')
ajax_template_name = 'part/bom_duplicate.html'
form_class = part_forms.BomDuplicateForm
role_required = 'part.change'
def get_form(self):
form = super().get_form()
# Limit choices to parents of the current part
parents = self.get_object().get_ancestors()
form.fields['parent'].queryset = parents
return form
def get_initial(self):
initials = super().get_initial()
parents = self.get_object().get_ancestors()
if parents.count() == 1:
initials['parent'] = parents[0]
return initials
def validate(self, part, form):
confirm = str2bool(form.cleaned_data.get('confirm', False))
if not confirm:
form.add_error('confirm', _('Confirm duplication of BOM from parent'))
def save(self, part, form):
"""
Duplicate BOM from the specified parent
"""
parent = form.cleaned_data.get('parent', None)
clear = str2bool(form.cleaned_data.get('clear', True))
if parent:
part.copy_bom_from(parent, clear=clear)
class BomValidate(AjaxUpdateView): class BomValidate(AjaxUpdateView):
""" Modal form view for validating a part BOM """ """
Modal form view for validating a part BOM
"""
model = Part model = Part
ajax_form_title = _("Validate BOM") ajax_form_title = _("Validate BOM")
@ -852,24 +994,25 @@ class BomValidate(AjaxUpdateView):
return self.renderJsonResponse(request, form, context=self.get_context()) return self.renderJsonResponse(request, form, context=self.get_context())
def post(self, request, *args, **kwargs): def validate(self, part, form, **kwargs):
form = self.get_form() confirm = str2bool(form.cleaned_data.get('validate', False))
part = self.get_object()
confirmed = str2bool(request.POST.get('validate', False)) if not confirm:
form.add_error('validate', _('Confirm that the BOM is valid'))
if confirmed: def save(self, part, form, **kwargs):
part.validate_bom(request.user) """
else: Mark the BOM as validated
form.errors['validate'] = ['Confirm that the BOM is valid'] """
data = { part.validate_bom(self.request.user)
'form_valid': confirmed
def get_data(self):
return {
'success': _('Validated Bill of Materials')
} }
return self.renderJsonResponse(request, form, data, context=self.get_context())
class BomUpload(InvenTreeRoleMixin, FormView): class BomUpload(InvenTreeRoleMixin, FormView):
""" View for uploading a BOM file, and handling BOM data importing. """ View for uploading a BOM file, and handling BOM data importing.
@ -1001,7 +1144,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
bom_file_valid = False bom_file_valid = False
if bom_file is None: if bom_file is None:
self.form.errors['bom_file'] = [_('No BOM file provided')] self.form.add_error('bom_file', _('No BOM file provided'))
else: else:
# Create a BomUploadManager object - will perform initial data validation # Create a BomUploadManager object - will perform initial data validation
# (and raise a ValidationError if there is something wrong with the file) # (and raise a ValidationError if there is something wrong with the file)
@ -1012,7 +1155,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
errors = e.error_dict errors = e.error_dict
for k, v in errors.items(): for k, v in errors.items():
self.form.errors[k] = v self.form.add_error(k, v)
if bom_file_valid: if bom_file_valid:
# BOM file is valid? Proceed to the next step! # BOM file is valid? Proceed to the next step!
@ -2097,7 +2240,7 @@ class BomItemCreate(AjaxCreateView):
model = BomItem model = BomItem
form_class = part_forms.EditBomItemForm form_class = part_forms.EditBomItemForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = _('Create BOM item') ajax_form_title = _('Create BOM Item')
role_required = 'part.add' role_required = 'part.add'
@ -2127,7 +2270,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
@ -2195,7 +2338,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)

View File

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

View File

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

View File

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

View File

@ -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
@ -198,7 +199,8 @@ 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:
@ -814,14 +825,11 @@ class StockItem(MPTTModel):
raise ValidationError({"quantity": _("Quantity does not match serial numbers")}) raise ValidationError({"quantity": _("Quantity does not match serial numbers")})
# Test if each of the serial numbers are valid # Test if each of the serial numbers are valid
existing = [] existing = self.part.find_conflicting_serial_numbers(serials)
for serial in serials:
if self.part.checkIfSerialNumberExists(serial):
existing.append(serial)
if len(existing) > 0: if len(existing) > 0:
raise ValidationError({"serial_numbers": _("Serial numbers already exist: ") + str(existing)}) exists = ','.join([str(x) for x in existing])
raise ValidationError({"serial_numbers": _("Serial numbers already exist") + ': ' + exists})
# Create a new stock item for each unique serial number # Create a new stock item for each unique serial number
for serial in serials: for serial in serials:
@ -1052,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)
@ -1081,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)
@ -1107,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)
@ -1129,6 +1137,22 @@ class StockItem(MPTTModel):
return s return s
@transaction.atomic
def clear_test_results(self, **kwargs):
"""
Remove all test results
kwargs:
TODO
"""
# All test results
results = self.test_results.all()
# TODO - Perhaps some filtering options supplied by kwargs?
results.delete()
def getTestResults(self, test=None, result=None, user=None): def getTestResults(self, test=None, result=None, user=None):
""" """
Return all test results associated with this StockItem. Return all test results associated with this StockItem.

View File

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

View File

@ -14,7 +14,9 @@
{% if roles.stock.change %} {% if roles.stock.change %}
<div id='table-toolbar'> <div id='table-toolbar'>
<div class='btn-group'> <div class='btn-group'>
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>New Entry</button> <button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>
<span class='fas fa-plus-circle'></span> {% trans "New Entry" %}
</button>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -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 %}
@ -234,7 +247,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% endif %} {% endif %}
{% if item.batch %} {% if item.batch %}
<tr> <tr>
<td></td> <td><span class='fas fa-layer-group'></span></td>
<td>{% trans "Batch" %}</td> <td>{% trans "Batch" %}</td>
<td>{{ item.batch }}</td> <td>{{ item.batch }}</td>
</tr> </tr>
@ -248,7 +261,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% endif %} {% endif %}
{% if item.purchase_order %} {% if item.purchase_order %}
<tr> <tr>
<td></td> <td><span class='fas fa-shopping-cart'></span></td>
<td>{% trans "Purchase Order" %}</td> <td>{% trans "Purchase Order" %}</td>
<td><a href="{% url 'po-detail' item.purchase_order.id %}">{{ item.purchase_order }}</a></td> <td><a href="{% url 'po-detail' item.purchase_order.id %}">{{ item.purchase_order }}</a></td>
</tr> </tr>
@ -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>

View File

@ -14,10 +14,16 @@
<div class='button-toolbar container-fluid' style="float: right;"> <div class='button-toolbar container-fluid' style="float: right;">
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if user.is_staff %} {% if user.is_staff %}
<button type='button' class='btn btn-danger' id='delete-test-results'>{% trans "Delete Test Data" %}</button> <button type='button' class='btn btn-danger' id='delete-test-results'>
<span class='fas fa-trash-alt'></span> {% trans "Delete Test Data" %}
</button>
{% endif %} {% endif %}
<button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Data" %}</button> <button type='button' class='btn btn-success' id='add-test-result'>
<button type='button' class='btn btn-default' id='test-report'>{% trans "Test Report" %} <span class='fas fa-tasks'></span></button> <span class='fas fa-plus-circle'></span> {% trans "Add Test Data" %}
</button>
<button type='button' class='btn btn-default' id='test-report'>
<span class='fas fa-tasks'></span> {% trans "Test Report" %}
</button>
</div> </div>
<div class='filter-list' id='filter-list-stocktests'> <div class='filter-list' id='filter-list-stocktests'>
<!-- Empty div --> <!-- Empty div -->

View File

@ -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()
@ -407,6 +412,13 @@ class VariantTest(StockTest):
self.assertEqual(chair.getLatestSerialNumber(), '22') self.assertEqual(chair.getLatestSerialNumber(), '22')
# Check for conflicting serial numbers
to_check = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
conflicts = chair.find_conflicting_serial_numbers(to_check)
self.assertEqual(len(conflicts), 6)
# Same operations on a sub-item # Same operations on a sub-item
variant = Part.objects.get(pk=10003) variant = Part.objects.get(pk=10003)
self.assertEqual(variant.getLatestSerialNumber(), '22') self.assertEqual(variant.getLatestSerialNumber(), '22')
@ -516,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()

View File

@ -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,11 +164,12 @@ 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, **kwargs): def save(self, form, **kwargs):
""" Record the user that uploaded the attachment """ """ Record the user that uploaded the attachment """
self.object.user = self.request.user attachment = form.save(commit=False)
self.object.save() attachment.user = self.request.user
attachment.save()
def get_data(self): def get_data(self):
return { return {
@ -245,32 +246,28 @@ class StockItemAssignToCustomer(AjaxUpdateView):
form_class = StockForms.AssignStockItemToCustomerForm form_class = StockForms.AssignStockItemToCustomerForm
role_required = 'stock.change' role_required = 'stock.change'
def post(self, request, *args, **kwargs): def validate(self, item, form, **kwargs):
customer = request.POST.get('customer', None) customer = form.cleaned_data.get('customer', None)
if not customer:
form.add_error('customer', _('Customer must be specified'))
def save(self, item, form, **kwargs):
"""
Assign the stock item to the customer.
"""
customer = form.cleaned_data.get('customer', None)
if customer: if customer:
try: item = item.allocateToCustomer(
customer = Company.objects.get(pk=customer)
except (ValueError, Company.DoesNotExist):
customer = None
if customer is not None:
stock_item = self.get_object()
item = stock_item.allocateToCustomer(
customer, customer,
user=request.user user=self.request.user
) )
item.clearAllocations() item.clearAllocations()
data = {
'form_valid': True,
}
return self.renderJsonResponse(request, self.get_form(), data)
class StockItemReturnToStock(AjaxUpdateView): class StockItemReturnToStock(AjaxUpdateView):
""" """
@ -283,30 +280,25 @@ class StockItemReturnToStock(AjaxUpdateView):
form_class = StockForms.ReturnStockItemForm form_class = StockForms.ReturnStockItemForm
role_required = 'stock.change' role_required = 'stock.change'
def post(self, request, *args, **kwargs): def validate(self, item, form, **kwargs):
location = request.POST.get('location', None) location = form.cleaned_data.get('location', None)
if not location:
form.add_error('location', _('Specify a valid location'))
def save(self, item, form, **kwargs):
location = form.cleaned_data.get('location', None)
if location: if location:
try: item.returnFromCustomer(location, self.request.user)
location = StockLocation.objects.get(pk=location)
except (ValueError, StockLocation.DoesNotExist):
location = None
if location: def get_data(self):
stock_item = self.get_object() return {
'success': _('Stock item returned from customer')
stock_item.returnFromCustomer(location, request.user)
else:
raise ValidationError({'location': _("Specify a valid location")})
data = {
'form_valid': True,
'success': _("Stock item returned from customer")
} }
return self.renderJsonResponse(request, self.get_form(), data)
class StockItemSelectLabels(AjaxView): class StockItemSelectLabels(AjaxView):
""" """
@ -417,8 +409,8 @@ class StockItemDeleteTestData(AjaxUpdateView):
confirm = str2bool(request.POST.get('confirm', False)) confirm = str2bool(request.POST.get('confirm', False))
if confirm is not True: if confirm is not True:
form.errors['confirm'] = [_('Confirm test data deletion')] form.add_error('confirm', _('Confirm test data deletion'))
form.non_field_errors = [_('Check the confirmation box')] form.add_error(None, _('Check the confirmation box'))
else: else:
stock_item.test_results.all().delete() stock_item.test_results.all().delete()
valid = True valid = True
@ -440,11 +432,14 @@ 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, **kwargs): def save(self, form, **kwargs):
""" Record the user that uploaded the test result """ """
Record the user that uploaded the test result
"""
self.object.user = self.request.user result = form.save(commit=False)
self.object.save() result.user = self.request.user
result.save()
def get_initial(self): def get_initial(self):
@ -918,7 +913,7 @@ class StockItemUninstall(AjaxView, FormMixin):
if not confirmed: if not confirmed:
valid = False valid = False
form.errors['confirm'] = [_('Confirm stock adjustment')] form.add_error('confirm', _('Confirm stock adjustment'))
data = { data = {
'form_valid': valid, 'form_valid': valid,
@ -1116,7 +1111,7 @@ class StockAdjust(AjaxView, FormMixin):
if not confirmed: if not confirmed:
valid = False valid = False
form.errors['confirm'] = [_('Confirm stock adjustment')] form.add_error('confirm', _('Confirm stock adjustment'))
data = { data = {
'form_valid': valid, 'form_valid': valid,
@ -1414,9 +1409,9 @@ 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.errors['serial_numbers'] = e.messages form.add_error('serial_numbers', e.messages)
valid = False valid = False
numbers = [] numbers = []
@ -1428,9 +1423,9 @@ class StockItemSerialize(AjaxUpdateView):
for k in messages.keys(): for k in messages.keys():
if k in ['quantity', 'destination', 'serial_numbers']: if k in ['quantity', 'destination', 'serial_numbers']:
form.errors[k] = messages[k] form.add_error(k, messages[k])
else: else:
form.non_field_errors = [messages[k]] form.add_error(None, messages[k])
valid = False valid = False
@ -1510,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:
@ -1539,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
@ -1622,14 +1616,14 @@ class StockItemCreate(AjaxCreateView):
part = None part = None
quantity = 1 quantity = 1
valid = False valid = False
form.errors['quantity'] = [_('Invalid quantity')] form.add_error('quantity', _('Invalid quantity'))
if quantity < 0: if quantity < 0:
form.errors['quantity'] = [_('Quantity cannot be less than zero')] form.add_error('quantity', _('Quantity cannot be less than zero'))
valid = False valid = False
if part is None: if part is None:
form.errors['part'] = [_('Invalid part selection')] form.add_error('part', _('Invalid part selection'))
else: else:
# A trackable part must provide serial numbesr # A trackable part must provide serial numbesr
if part.trackable: if part.trackable:
@ -1640,17 +1634,16 @@ 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 = [] existing = part.find_conflicting_serial_numbers(serials)
for serial in serials:
if part.checkIfSerialNumberExists(serial):
existing.append(serial)
if len(existing) > 0: if len(existing) > 0:
exists = ",".join([str(x) for x in existing]) exists = ",".join([str(x) for x in existing])
form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))] form.add_error(
'serial_numbers',
_('Serial numbers already exist') + ': ' + exists
)
valid = False valid = False
else: else:
@ -1682,7 +1675,7 @@ class StockItemCreate(AjaxCreateView):
valid = True valid = True
except ValidationError as e: except ValidationError as e:
form.errors['serial_numbers'] = e.messages form.add_error('serial_numbers', e.messages)
valid = False valid = False
else: else:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More