Add forms / views for creating a new build output, and completing the build

- Also some refactoring of how forms are handled and saved
This commit is contained in:
Oliver Walters 2020-11-02 22:56:26 +11:00
parent b02c87ea50
commit 500da8099b
18 changed files with 495 additions and 521 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

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

@ -213,26 +213,6 @@ class AjaxMixin(InvenTreeRoleMixin):
""" """
return {} return {}
def pre_save(self, obj, form, **kwargs):
"""
Hook for doing something *before* an object is saved.
obj: The object to be saved
form: The cleaned form
"""
# Do nothing by default
pass
def post_save(self, obj, form, **kwargs):
"""
Hook for doing something *after* an object is saved.
"""
# Do nothing by default
pass
def validate(self, obj, form, **kwargs): def validate(self, obj, form, **kwargs):
""" """
Hook for performing custom form validation steps. Hook for performing custom form validation steps.
@ -362,7 +342,7 @@ class AjaxCreateView(AjaxMixin, CreateView):
form = self.get_form() form = self.get_form()
return self.renderJsonResponse(request, form) return self.renderJsonResponse(request, form)
def do_save(self, form): def save(self, form):
""" """
Method for actually saving the form to the database. Method for actually saving the form to the database.
Default implementation is very simple, Default implementation is very simple,
@ -402,14 +382,10 @@ class AjaxCreateView(AjaxMixin, CreateView):
if valid: if valid:
# Perform (optional) pre-save step
self.pre_save(None, self.form)
# Save the object to the database # Save the object to the database
self.do_save(self.form) self.save(self.form)
# Perform (optional) post-save step self.object = self.get_object()
self.post_save(self.object, self.form)
# Return the PK of the newly-created object # Return the PK of the newly-created object
data['pk'] = self.object.pk data['pk'] = self.object.pk
@ -440,11 +416,14 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data()) return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data())
def do_save(self, form): def save(self, object, form, **kwargs):
""" """
Method for updating the object in the database. Method for updating the object in the database.
Default implementation is very simple, Default implementation is very simple, but can be overridden if required.
but can be overridden if required.
Args:
object - The current object, to be updated
form - The validated form
""" """
self.object = form.save() self.object = form.save()
@ -485,22 +464,16 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
if valid: if valid:
# Perform (optional) pre-save step
self.pre_save(self.object, form)
# Save the updated objec to the database # Save the updated objec to the database
obj = self.do_save(form) self.save(self.object, form)
# Perform (optional) post-save step self.object = self.get_object()
self.post_save(obj, form)
# Include context data about the updated object # Include context data about the updated object
data['pk'] = obj.pk data['pk'] = self.object.pk
self.post_save(obj, form)
try: try:
data['url'] = obj.get_absolute_url() data['url'] = self.object.get_absolute_url()
except AttributeError: except AttributeError:
pass pass

View File

@ -32,12 +32,6 @@ class EditBuildForm(HelperForm):
'reference': _('Build Order reference') 'reference': _('Build Order reference')
} }
serial_numbers = forms.CharField(
label=_('Serial Numbers'),
help_text=_('Serial numbers for build outputs'),
required=False,
)
class Meta: class Meta:
model = Build model = Build
fields = [ fields = [
@ -46,7 +40,6 @@ class EditBuildForm(HelperForm):
'part', 'part',
'quantity', 'quantity',
'batch', 'batch',
'serial_numbers',
'take_from', 'take_from',
'destination', 'destination',
'parent', 'parent',
@ -55,6 +48,42 @@ class EditBuildForm(HelperForm):
] ]
class BuildOutputCreateForm(HelperForm):
"""
Form for creating a new build output.
"""
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:
model = Build
fields = [
'quantity',
'batch',
'serial_numbers',
'confirm',
]
class BuildOutputDeleteForm(HelperForm): class BuildOutputDeleteForm(HelperForm):
""" """
Form for deleting a build output. Form for deleting a build output.
@ -123,7 +152,27 @@ class AutoAllocateForm(HelperForm):
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',

View File

@ -250,7 +250,7 @@ class Build(MPTTModel):
@property @property
def incomplete_outputs(self): def incomplete_outputs(self):
""" """
Return all the "incomplete" build outputs" Return all the "incomplete" build outputs
""" """
outputs = self.get_build_outputs(complete=False) outputs = self.get_build_outputs(complete=False)
@ -259,6 +259,19 @@ class Build(MPTTModel):
return outputs 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):
""" """
@ -291,6 +304,37 @@ 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
def completeBuild(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()
@transaction.atomic @transaction.atomic
def cancelBuild(self, user): def cancelBuild(self, user):
""" Mark the Build as CANCELLED """ Mark the Build as CANCELLED
@ -408,6 +452,77 @@ class Build(MPTTModel):
# Remove all the allocations # Remove all the allocations
allocations.delete() allocations.delete()
@transaction.atomic
def create_build_output(self, quantity, **kwargs):
"""
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
output = 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
"""
output = StockModels.StockItem.objects.create(
quantity=quantity,
location=location,
part=self.part,
build=self,
batch=batch,
is_building=True
)
@transaction.atomic
def deleteBuildOutput(self, output): def deleteBuildOutput(self, output):
""" """
Remove a build output from the database: Remove a build output from the database:

View File

@ -11,11 +11,19 @@ InvenTree | Allocate Parts
{% include "build/tabs.html" with tab='allocate' %} {% include "build/tabs.html" with tab='allocate' %}
<div class='btn-group' role='group'> <h4>{% trans "Incomplete Build Ouputs" %}</h4>
<hr>
<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>
</div> </div>
<h4>{% trans "Incomplete Build Ouputs" %}</h4>
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true"> <div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
{% for item in build.incomplete_outputs %} {% for item in build.incomplete_outputs %}
{% include "build/allocation_card.html" with item=item %} {% include "build/allocation_card.html" with item=item %}
@ -57,388 +65,6 @@ InvenTree | Allocate Parts
); );
{% endfor %} {% endfor %}
var buildTable = $("#build-item-list");
// Calculate sum of allocations for a particular table row
function sumAllocations(row) {
if (row.allocations == null) {
return 0;
}
var quantity = 0;
row.allocations.forEach(function(item) {
quantity += item.quantity;
});
return quantity;
}
function getUnallocated(row) {
// Return the number of items remaining to be allocated for a given row
return {{ build.quantity }} * row.quantity - sumAllocations(row);
}
function setExpandedAllocatedLocation(row) {
// Handle case when stock item does not have a location set
if (row.location_detail == null) {
return 'No stock location set';
} else {
return row.location_detail.pathstring;
}
}
function reloadTable() {
// Reload the build allocation table
buildTable.bootstrapTable('refresh');
}
function setupCallbacks() {
// Register button callbacks once the table data are loaded
buildTable.find(".button-add").click(function() {
var pk = $(this).attr('pk');
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = buildTable.bootstrapTable('getData')[idx];
launchModalForm('/build/item/new/', {
success: reloadTable,
data: {
part: row.sub_part,
build: {{ build.id }},
quantity: getUnallocated(row),
},
secondary: [
{
field: 'stock_item',
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 %}
],
});
{% if build.status == BuildStatus.PENDING %} {% if build.status == BuildStatus.PENDING %}
$("#btn-allocate").on('click', function() { $("#btn-allocate").on('click', function() {
launchModalForm( launchModalForm(
@ -458,6 +84,14 @@ InvenTree | Allocate Parts
); );
}); });
$('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
{
reload: true,
}
);
});
$("#btn-order-parts").click(function() { $("#btn-order-parts").click(function() {
launchModalForm("/order/purchase-order/order-parts/", { launchModalForm("/order/purchase-order/order-parts/", {
data: { data: {

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

@ -1,48 +1,23 @@
{% extends "modal_form.html" %} {% extends "modal_form.html" %}
{% load inventree_extras %}
{% load i18n %} {% load i18n %}
{% block pre_form_content %} {% block pre_form_content %}
{% if fully_allocated %} {% if build.can_complete %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-success'>
<h4>{% trans "Stock allocation is complete" %}</h4> {% trans "Build can be completed" %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
<h4>{% trans "Stock allocation is incomplete" %}</h4> <b>{% trans "Build cannot be completed" %}</b><br>
<ul>
<div class='panel-group'> {% if build.incomplete_count > 0 %}
<div class='panel panel-default'> <li>{% trans "Incompleted build outputs remain" %}</li>
<div class='panel panel-heading'> {% endif %}
<a data-toggle='collapse' href='#collapse-unallocated'> {% if build.completed < build.quantity %}
{{ unallocated_parts|length }} {% trans "parts have not been fully allocated" %} <li>{% trans "Required build quantity has not been completed" %}</li>
</a> {% endif %}
</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> </ul>
</div>
</div>
</div>
</div>
</div> </div>
{% endif %} {% 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 %} {% 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

@ -10,7 +10,7 @@ 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 from InvenTree.helpers import extract_serial_numbers
class BuildTest(TestCase): class BuildTest(TestCase):
@ -188,7 +188,7 @@ class BuildTest(TestCase):
self.assertTrue(self.build.isFullyAllocated()) self.assertTrue(self.build.isFullyAllocated())
# Generate some serial numbers! # Generate some serial numbers!
serials = ExtractSerialNumbers("1-10", 10) serials = extract_serial_numbers("1-10", 10)
self.build.completeBuild(None, serials, None) self.build.completeBuild(None, serials, None)

View File

@ -11,10 +11,12 @@ 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'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
url(r'^complete/?', views.BuildComplete.as_view(), name='build-complete'), 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'),

View File

@ -18,7 +18,7 @@ from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, ExtractSerialNumbers, normalize from InvenTree.helpers import str2bool, extract_serial_numbers, normalize
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
@ -67,11 +67,13 @@ class BuildCancel(AjaxUpdateView):
if not confirm: if not confirm:
form.add_error('confirm_cancel', _('Confirm build cancellation')) form.add_error('confirm_cancel', _('Confirm build cancellation'))
def post_save(self, build, form, **kwargs): def save(self, form, **kwargs):
""" """
Cancel the build. Cancel the build.
""" """
build = self.get_object()
build.cancelBuild(self.request.user) build.cancelBuild(self.request.user)
def get_data(self): def get_data(self):
@ -156,13 +158,12 @@ class BuildAutoAllocate(AjaxUpdateView):
if not output: if not output:
form.add_error(None, _('Build output must be specified')) form.add_error(None, _('Build output must be specified'))
def post_save(self, build, form, **kwargs): def save(self, build, form, **kwargs):
""" """
Once the form has been validated, Once the form has been validated,
perform auto-allocations perform auto-allocations
""" """
build = self.get_object()
output = form.cleaned_data.get('output', None) output = form.cleaned_data.get('output', None)
build.autoAllocate(output) build.autoAllocate(output)
@ -173,6 +174,99 @@ class BuildAutoAllocate(AjaxUpdateView):
} }
class BuildOutputCreate(AjaxUpdateView):
"""
Create a new build output (StockItem) for a given build.
"""
model = Build
form_class = forms.BuildOutputCreateForm
ajax_template_name = 'build/build_output_create.html'
ajax_form_title = _('Create Build Output')
role_required = 'build.change'
def validate(self, build, form, **kwargs):
"""
Validation for the form:
"""
quantity = form.cleaned_data.get('quantity', None)
serials = form.cleaned_data.get('serial_numbers', None)
# Check that the serial numbers are valid
if serials:
try:
extracted = extract_serial_numbers(serials, quantity)
if extracted:
# Check for conflicting serial numbers
conflicts = build.part.find_conflicting_serial_numbers(extracted)
if len(conflicts) > 0:
msg = ",".join([str(c) for c in conflicts])
form.add_error(
'serial_numbers',
_('Serial numbers already exist') + ': ' + msg,
)
except ValidationError as e:
form.add_error('serial_numbers', e.messages)
else:
# If no serial numbers are provided, should they be?
if build.part.trackable:
form.add_error('serial_numbers', _('Serial numbers required for trackable build output'))
def save(self, build, form, **kwargs):
"""
Create a new build output
"""
data = form.cleaned_data
quantity = data.get('quantity', None)
batch = data.get('batch', None)
serials = data.get('serial_numbers', None)
if serials:
serial_numbers = extract_serial_numbers(serials, quantity)
else:
serial_numbers = None
build.create_build_output(
quantity,
serials=serial_numbers,
batch=batch,
)
def get_initial(self):
initials = super().get_initial()
build = self.get_object()
# Calculate the required quantity
quantity = max(0, build.remaining - build.incomplete_count)
initials['quantity'] = quantity
return initials
def get_form(self):
form = super().get_form()
build = self.get_object()
part = build.part
# If the part is not trackable, hide the serial number input
if not part.trackable:
form.fields['serial_numbers'] = HiddenInput()
return form
class BuildOutputDelete(AjaxUpdateView): class BuildOutputDelete(AjaxUpdateView):
""" """
Delete a build output (StockItem) for a given build. Delete a build output (StockItem) for a given build.
@ -182,7 +276,7 @@ class BuildOutputDelete(AjaxUpdateView):
model = Build model = Build
form_class = forms.BuildOutputDeleteForm form_class = forms.BuildOutputDeleteForm
ajax_form_title = _('Delete build output') ajax_form_title = _('Delete Build Output')
role_required = 'build.delete' role_required = 'build.delete'
def get_initial(self): def get_initial(self):
@ -296,7 +390,24 @@ class BuildUnallocate(AjaxUpdateView):
class BuildComplete(AjaxUpdateView): class BuildComplete(AjaxUpdateView):
""" View to mark a build as Complete. """
View to mark the build as complete.
Requirements:
- There can be no outstanding build outputs
- The "completed" value must meet or exceed the "quantity" value
"""
model = Build
form_class = forms.CompleteBuildForm
role_required = 'build.change'
ajax_form_title = _('Complete Build Order')
ajax_template_name = 'build/complete.html'
class BuildOutputComplete(AjaxUpdateView):
"""
View to mark a particular build output as Complete.
- Notifies the user of which parts will be removed from stock. - Notifies the user of which parts will be removed from stock.
- Removes allocated items from stock - Removes allocated items from stock
@ -304,10 +415,10 @@ class BuildComplete(AjaxUpdateView):
""" """
model = Build model = Build
form_class = forms.CompleteBuildForm form_class = forms.CompleteBuildOutputForm
context_object_name = "build" context_object_name = "build"
ajax_form_title = _("Complete Build Output") ajax_form_title = _("Complete Build Output")
ajax_template_name = "build/complete.html" ajax_template_name = "build/complete_output.html"
role_required = 'build.change' role_required = 'build.change'
def get_form(self): def get_form(self):
@ -422,7 +533,7 @@ class BuildComplete(AjaxUpdateView):
return context return context
def post_save(self, build, form, **kwargs): def save(self, build, form, **kwargs):
data = form.cleaned_data data = form.cleaned_data
@ -593,7 +704,7 @@ class BuildCreate(AjaxCreateView):
# Check that the provided serial numbers are sensible # Check that the provided serial numbers are sensible
try: try:
extracted = ExtractSerialNumbers(serials, quantity) extracted = extract_serial_numbers(serials, quantity)
except ValidationError as e: except ValidationError as e:
extracted = None extracted = None
form.add_error('serial_numbers', e.messages) form.add_error('serial_numbers', e.messages)
@ -912,9 +1023,14 @@ class BuildAttachmentCreate(AjaxCreateView):
ajax_form_title = _('Add Build Order Attachment') ajax_form_title = _('Add Build Order Attachment')
role_required = 'build.add' role_required = 'build.add'
def post_save(self, **kwargs): def save(self, form, **kwargs):
self.object.user = self.request.user """
self.object.save() Add information on 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 {

View File

@ -100,7 +100,9 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
ajax_template_name = "modal_form.html" ajax_template_name = "modal_form.html"
role_required = 'purchase_order.add' role_required = 'purchase_order.add'
def post_save(self, attachment, form, **kwargs): def save(self, form, **kwargs):
attachment = form.save(commit=False)
attachment.user = self.request.user attachment.user = self.request.user
attachment.save() attachment.save()
@ -148,9 +150,14 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
ajax_form_title = _('Add Sales Order Attachment') ajax_form_title = _('Add Sales Order Attachment')
role_required = 'sales_order.add' role_required = 'sales_order.add'
def post_save(self, attachment, form, **kwargs): def save(self, form, **kwargs):
self.object.user = self.request.user """
self.object.save() Save the user that uploaded the attachment
"""
attachment = form.save(commit=False)
attachment.user = self.request.user
attachment.save()
def get_data(self): def get_data(self):
return { return {
@ -295,7 +302,9 @@ class SalesOrderNotes(InvenTreeRoleMixin, UpdateView):
class PurchaseOrderCreate(AjaxCreateView): class PurchaseOrderCreate(AjaxCreateView):
""" View for creating a new PurchaseOrder object using a modal form """ """
View for creating a new PurchaseOrder object using a modal form
"""
model = PurchaseOrder model = PurchaseOrder
ajax_form_title = _("Create Purchase Order") ajax_form_title = _("Create Purchase Order")
@ -319,9 +328,12 @@ class PurchaseOrderCreate(AjaxCreateView):
return initials return initials
def post_save(self, order, form, **kwargs): def save(self, form, **kwargs):
# Record the user who created this purchase order """
Record the user who created this PurchaseOrder
"""
order = form.save(commit=False)
order.created_by = self.request.user order.created_by = self.request.user
order.save() order.save()
@ -351,8 +363,12 @@ class SalesOrderCreate(AjaxCreateView):
return initials return initials
def post_save(self, order, form, **kwargs): def save(self, form, **kwargs):
# Record the user who created this sales order """
Record the user who created this SalesOrder
"""
order = form.save(commit=False)
order.created_by = self.request.user order.created_by = self.request.user
order.save() order.save()
@ -414,7 +430,10 @@ class PurchaseOrderCancel(AjaxUpdateView):
if not order.can_cancel(): if not order.can_cancel():
form.add_error(None, _('Order cannot be cancelled')) form.add_error(None, _('Order cannot be cancelled'))
def post_save(self, order, form, **kwargs): def save(self, order, form, **kwargs):
"""
Cancel the PurchaseOrder
"""
order.cancel_order() order.cancel_order()
@ -438,7 +457,10 @@ class SalesOrderCancel(AjaxUpdateView):
if not order.can_cancel(): if not order.can_cancel():
form.add_error(None, _('Order cannot be cancelled')) form.add_error(None, _('Order cannot be cancelled'))
def post_save(self, order, form, **kwargs): def save(self, order, form, **kwargs):
"""
Once the form has been validated, cancel the SalesOrder
"""
order.cancel_order() order.cancel_order()
@ -459,8 +481,10 @@ class PurchaseOrderIssue(AjaxUpdateView):
if not confirm: if not confirm:
form.add_error('confirm', _('Confirm order placement')) form.add_error('confirm', _('Confirm order placement'))
def post_save(self, order, form, **kwargs): def save(self, order, form, **kwargs):
"""
Once the form has been validated, place the order.
"""
order.place_order() order.place_order()
def get_data(self): def get_data(self):
@ -495,7 +519,10 @@ class PurchaseOrderComplete(AjaxUpdateView):
if not confirm: if not confirm:
form.add_error('confirm', _('Confirm order completion')) form.add_error('confirm', _('Confirm order completion'))
def post_save(self, order, form, **kwargs): def save(self, order, form, **kwargs):
"""
Complete the PurchaseOrder
"""
order.complete_order() order.complete_order()

View File

@ -905,6 +905,7 @@ 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): def has_trackable_parts(self):
""" """
Return True if any parts linked in the Bill of Materials are trackable. Return True if any parts linked in the Bill of Materials are trackable.

View File

@ -82,10 +82,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 {
@ -874,7 +878,10 @@ class BomDuplicate(AjaxUpdateView):
if not confirm: if not confirm:
form.add_error('confirm', _('Confirm duplication of BOM from parent')) form.add_error('confirm', _('Confirm duplication of BOM from parent'))
def post_save(self, part, form): def save(self, part, form):
"""
Duplicate BOM from the specified parent
"""
parent = form.cleaned_data.get('parent', None) parent = form.cleaned_data.get('parent', None)
@ -915,7 +922,10 @@ class BomValidate(AjaxUpdateView):
if not confirm: if not confirm:
form.add_error('validate', _('Confirm that the BOM is valid')) form.add_error('validate', _('Confirm that the BOM is valid'))
def post_save(self, part, form, **kwargs): def save(self, part, form, **kwargs):
"""
Mark the BOM as validated
"""
part.validate_bom(self.request.user) part.validate_bom(self.request.user)

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,9 +164,10 @@ class StockItemAttachmentCreate(AjaxCreateView):
ajax_template_name = "modal_form.html" ajax_template_name = "modal_form.html"
role_required = 'stock.add' role_required = 'stock.add'
def post_save(self, attachment, form, **kwargs): def save(self, form, **kwargs):
""" Record the user that uploaded the attachment """ """ Record the user that uploaded the attachment """
attachment = form.save(commit=False)
attachment.user = self.request.user attachment.user = self.request.user
attachment.save() attachment.save()
@ -252,7 +253,7 @@ class StockItemAssignToCustomer(AjaxUpdateView):
if not customer: if not customer:
form.add_error('customer', _('Customer must be specified')) form.add_error('customer', _('Customer must be specified'))
def post_save(self, item, form, **kwargs): def save(self, item, form, **kwargs):
""" """
Assign the stock item to the customer. Assign the stock item to the customer.
""" """
@ -286,7 +287,7 @@ class StockItemReturnToStock(AjaxUpdateView):
if not location: if not location:
form.add_error('location', _('Specify a valid location')) form.add_error('location', _('Specify a valid location'))
def post_save(self, item, form, **kwargs): def save(self, item, form, **kwargs):
location = form.cleaned_data.get('location', None) location = form.cleaned_data.get('location', None)
@ -431,9 +432,12 @@ class StockItemTestResultCreate(AjaxCreateView):
ajax_form_title = _("Add Test Result") ajax_form_title = _("Add Test Result")
role_required = 'stock.add' role_required = 'stock.add'
def post_save(self, result, form, **kwargs): def save(self, form, **kwargs):
""" Record the user that uploaded the test result """ """
Record the user that uploaded the test result
"""
result = form.save(commit=False)
result.user = self.request.user result.user = self.request.user
result.save() result.save()
@ -1405,7 +1409,7 @@ class StockItemSerialize(AjaxUpdateView):
destination = None destination = None
try: try:
numbers = ExtractSerialNumbers(serials, quantity) numbers = extract_serial_numbers(serials, quantity)
except ValidationError as e: except ValidationError as e:
form.add_error('serial_numbers', e.messages) form.add_error('serial_numbers', e.messages)
valid = False valid = False
@ -1630,7 +1634,7 @@ class StockItemCreate(AjaxCreateView):
# If user has specified a range of serial numbers # If user has specified a range of serial numbers
if len(sn) > 0: if len(sn) > 0:
try: try:
serials = ExtractSerialNumbers(sn, quantity) serials = extract_serial_numbers(sn, quantity)
existing = part.find_conflicting_serial_numbers(serials) existing = part.find_conflicting_serial_numbers(serials)

View File

@ -92,14 +92,14 @@ function makeBuildOutputActionButtons(output, buildInfo) {
data: { data: {
output: outputId, output: outputId,
}, },
success: reloadTable, reload: true,
} }
); );
}); });
$(panel).find(`#button-output-complete-${outputId}`).click(function() { $(panel).find(`#button-output-complete-${outputId}`).click(function() {
launchModalForm( launchModalForm(
`/build/${buildId}/complete/`, `/build/${buildId}/complete-output/`,
{ {
success: reloadTable, success: reloadTable,
data: { data: {