diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 921a45c112..37a8bf82e1 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -337,7 +337,7 @@ def DownloadFile(data, filename, content_type='application/text'):
return response
-def ExtractSerialNumbers(serials, expected_quantity):
+def extract_serial_numbers(serials, expected_quantity):
""" Attempt to extract serial numbers from an input string.
- Serial numbers must be integer values
- Serial numbers must be positive
diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py
index c46a059c8d..6630c0b0af 100644
--- a/InvenTree/InvenTree/tests.py
+++ b/InvenTree/InvenTree/tests.py
@@ -210,7 +210,7 @@ class TestSerialNumberExtraction(TestCase):
def test_simple(self):
- e = helpers.ExtractSerialNumbers
+ e = helpers.extract_serial_numbers
sn = e("1-5", 5)
self.assertEqual(len(sn), 5)
@@ -226,7 +226,7 @@ class TestSerialNumberExtraction(TestCase):
def test_failures(self):
- e = helpers.ExtractSerialNumbers
+ e = helpers.extract_serial_numbers
# Test duplicates
with self.assertRaises(ValidationError):
diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 57f80f1be7..e46eb80bf6 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -213,26 +213,6 @@ class AjaxMixin(InvenTreeRoleMixin):
"""
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):
"""
Hook for performing custom form validation steps.
@@ -362,7 +342,7 @@ class AjaxCreateView(AjaxMixin, CreateView):
form = self.get_form()
return self.renderJsonResponse(request, form)
- def do_save(self, form):
+ def save(self, form):
"""
Method for actually saving the form to the database.
Default implementation is very simple,
@@ -402,14 +382,10 @@ class AjaxCreateView(AjaxMixin, CreateView):
if valid:
- # Perform (optional) pre-save step
- self.pre_save(None, self.form)
-
# Save the object to the database
- self.do_save(self.form)
+ self.save(self.form)
- # Perform (optional) post-save step
- self.post_save(self.object, self.form)
+ self.object = self.get_object()
# Return the PK of the newly-created object
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())
- def do_save(self, form):
+ 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.
+ 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()
@@ -485,22 +464,16 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
if valid:
- # Perform (optional) pre-save step
- self.pre_save(self.object, form)
-
# Save the updated objec to the database
- obj = self.do_save(form)
+ self.save(self.object, form)
- # Perform (optional) post-save step
- self.post_save(obj, form)
+ self.object = self.get_object()
# Include context data about the updated object
- data['pk'] = obj.pk
-
- self.post_save(obj, form)
+ data['pk'] = self.object.pk
try:
- data['url'] = obj.get_absolute_url()
+ data['url'] = self.object.get_absolute_url()
except AttributeError:
pass
diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py
index 0d90797467..508dd1a45a 100644
--- a/InvenTree/build/forms.py
+++ b/InvenTree/build/forms.py
@@ -32,12 +32,6 @@ class EditBuildForm(HelperForm):
'reference': _('Build Order reference')
}
- serial_numbers = forms.CharField(
- label=_('Serial Numbers'),
- help_text=_('Serial numbers for build outputs'),
- required=False,
- )
-
class Meta:
model = Build
fields = [
@@ -46,7 +40,6 @@ class EditBuildForm(HelperForm):
'part',
'quantity',
'batch',
- 'serial_numbers',
'take_from',
'destination',
'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):
"""
Form for deleting a build output.
@@ -123,7 +152,27 @@ class AutoAllocateForm(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 = {
'serial_numbers': 'fa-hashtag',
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index 03997e2353..1cb79b57a1 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -250,7 +250,7 @@ class Build(MPTTModel):
@property
def incomplete_outputs(self):
"""
- Return all the "incomplete" build outputs"
+ Return all the "incomplete" build outputs
"""
outputs = self.get_build_outputs(complete=False)
@@ -259,6 +259,19 @@ class Build(MPTTModel):
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
def getNextBuildNumber(cls):
"""
@@ -291,6 +304,37 @@ class Build(MPTTModel):
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
def cancelBuild(self, user):
""" Mark the Build as CANCELLED
@@ -408,6 +452,77 @@ class Build(MPTTModel):
# Remove all the allocations
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):
"""
Remove a build output from the database:
diff --git a/InvenTree/build/templates/build/allocate.html b/InvenTree/build/templates/build/allocate.html
index bae06c2c16..ee5fb10d89 100644
--- a/InvenTree/build/templates/build/allocate.html
+++ b/InvenTree/build/templates/build/allocate.html
@@ -11,11 +11,19 @@ InvenTree | Allocate Parts
{% include "build/tabs.html" with tab='allocate' %}
+
{% for item in build.incomplete_outputs %}
{% include "build/allocation_card.html" with item=item %}
@@ -57,388 +65,6 @@ InvenTree | Allocate Parts
);
{% 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 = `
`;
-
- 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 = `
`;
-
- {% 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 += `
`;
-
- 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 = `
`;
- 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 += '
';
-
- return html;
- },
- }
- {% endif %}
- ],
- });
-
{% if build.status == BuildStatus.PENDING %}
$("#btn-allocate").on('click', function() {
launchModalForm(
@@ -457,6 +83,14 @@ InvenTree | Allocate Parts
}
);
});
+
+ $('#btn-create-output').click(function() {
+ launchModalForm('{% url "build-output-create" build.id %}',
+ {
+ reload: true,
+ }
+ );
+ });
$("#btn-order-parts").click(function() {
launchModalForm("/order/purchase-order/order-parts/", {
diff --git a/InvenTree/build/templates/build/build_output_create.html b/InvenTree/build/templates/build/build_output_create.html
new file mode 100644
index 0000000000..5de41695a6
--- /dev/null
+++ b/InvenTree/build/templates/build/build_output_create.html
@@ -0,0 +1,20 @@
+{% extends "modal_form.html" %}
+{% load i18n %}
+{% block pre_form_content %}
+
+{% if build.part.has_trackable_parts %}
+
+ {% trans "The Bill of Materials contains trackable parts" %}
+ {% trans "Build outputs must be generated individually." %}
+ {% trans "Multiple build outputs will be created based on the quantity specified." %}
+
+{% endif %}
+
+{% if build.part.trackable %}
+
+ {% trans "Trackable parts can have serial numbers specified" %}
+ {% trans "Enter serial numbers to generate multiple single build outputs" %}
+
+{% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/build/templates/build/cancel.html b/InvenTree/build/templates/build/cancel.html
index d7e4d51b10..48d8ca09bd 100644
--- a/InvenTree/build/templates/build/cancel.html
+++ b/InvenTree/build/templates/build/cancel.html
@@ -1,7 +1,7 @@
{% extends "modal_form.html" %}
-
+{% load i18n %}
{% block pre_form_content %}
-Are you sure you wish to cancel this build?
+{% trans "Are you sure you wish to cancel this build?" %}
{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/build/templates/build/complete.html b/InvenTree/build/templates/build/complete.html
index 54c3fc6763..0db6a336a5 100644
--- a/InvenTree/build/templates/build/complete.html
+++ b/InvenTree/build/templates/build/complete.html
@@ -1,48 +1,23 @@
{% extends "modal_form.html" %}
-{% load inventree_extras %}
{% load i18n %}
{% block pre_form_content %}
-{% if fully_allocated %}
-
-
{% trans "Stock allocation is complete" %}
+{% if build.can_complete %}
+
+ {% trans "Build can be completed" %}
{% else %}
-
{% trans "Stock allocation is incomplete" %}
-
-
-
-
-
-
-
- {% for part in unallocated_parts %}
- -
- {% include "hover_image.html" with image=part.image %} {{ part }}
-
- {% endfor %}
-
-
-
-
-
+
{% trans "Build cannot be completed" %}
+
+ {% if build.incomplete_count > 0 %}
+ - {% trans "Incompleted build outputs remain" %}
+ {% endif %}
+ {% if build.completed < build.quantity %}
+ - {% trans "Required build quantity has not been completed" %}
+ {% endif %}
+
{% endif %}
-
-
-
- {% trans "The following items will be created" %}
-
-
- {% include "hover_image.html" with image=build.part.image hover=True %}
- {% decimal output.quantity %} x {{ output.part.full_name }}
-
-
-
{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/build/templates/build/complete_output.html b/InvenTree/build/templates/build/complete_output.html
new file mode 100644
index 0000000000..54c3fc6763
--- /dev/null
+++ b/InvenTree/build/templates/build/complete_output.html
@@ -0,0 +1,48 @@
+{% extends "modal_form.html" %}
+{% load inventree_extras %}
+{% load i18n %}
+
+{% block pre_form_content %}
+
+{% if fully_allocated %}
+
+
{% trans "Stock allocation is complete" %}
+
+{% else %}
+
+
{% trans "Stock allocation is incomplete" %}
+
+
+
+
+
+
+
+ {% for part in unallocated_parts %}
+ -
+ {% include "hover_image.html" with image=part.image %} {{ part }}
+
+ {% endfor %}
+
+
+
+
+
+
+{% endif %}
+
+
+
+ {% trans "The following items will be created" %}
+
+
+ {% include "hover_image.html" with image=build.part.image hover=True %}
+ {% decimal output.quantity %} x {{ output.part.full_name }}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py
index 37d877a905..4e65a3a416 100644
--- a/InvenTree/build/test_build.py
+++ b/InvenTree/build/test_build.py
@@ -10,7 +10,7 @@ from stock.models import StockItem
from part.models import Part, BomItem
from InvenTree import status_codes as status
-from InvenTree.helpers import ExtractSerialNumbers
+from InvenTree.helpers import extract_serial_numbers
class BuildTest(TestCase):
@@ -188,7 +188,7 @@ class BuildTest(TestCase):
self.assertTrue(self.build.isFullyAllocated())
# Generate some serial numbers!
- serials = ExtractSerialNumbers("1-10", 10)
+ serials = extract_serial_numbers("1-10", 10)
self.build.completeBuild(None, serials, None)
diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py
index ff49fdd486..08142e6939 100644
--- a/InvenTree/build/urls.py
+++ b/InvenTree/build/urls.py
@@ -11,10 +11,12 @@ build_detail_urls = [
url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'),
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
+ url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
- url(r'^complete/?', 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'^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'),
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index 4716fd7583..04c013d381 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -18,7 +18,7 @@ from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
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
@@ -67,11 +67,13 @@ class BuildCancel(AjaxUpdateView):
if not confirm:
form.add_error('confirm_cancel', _('Confirm build cancellation'))
- def post_save(self, build, form, **kwargs):
+ def save(self, form, **kwargs):
"""
Cancel the build.
"""
+ build = self.get_object()
+
build.cancelBuild(self.request.user)
def get_data(self):
@@ -156,13 +158,12 @@ class BuildAutoAllocate(AjaxUpdateView):
if not output:
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,
perform auto-allocations
"""
- build = self.get_object()
output = form.cleaned_data.get('output', None)
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):
"""
Delete a build output (StockItem) for a given build.
@@ -182,7 +276,7 @@ class BuildOutputDelete(AjaxUpdateView):
model = Build
form_class = forms.BuildOutputDeleteForm
- ajax_form_title = _('Delete build output')
+ ajax_form_title = _('Delete Build Output')
role_required = 'build.delete'
def get_initial(self):
@@ -296,7 +390,24 @@ class BuildUnallocate(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.
- Removes allocated items from stock
@@ -304,10 +415,10 @@ class BuildComplete(AjaxUpdateView):
"""
model = Build
- form_class = forms.CompleteBuildForm
+ form_class = forms.CompleteBuildOutputForm
context_object_name = "build"
ajax_form_title = _("Complete Build Output")
- ajax_template_name = "build/complete.html"
+ ajax_template_name = "build/complete_output.html"
role_required = 'build.change'
def get_form(self):
@@ -422,7 +533,7 @@ class BuildComplete(AjaxUpdateView):
return context
- def post_save(self, build, form, **kwargs):
+ def save(self, build, form, **kwargs):
data = form.cleaned_data
@@ -593,7 +704,7 @@ class BuildCreate(AjaxCreateView):
# Check that the provided serial numbers are sensible
try:
- extracted = ExtractSerialNumbers(serials, quantity)
+ extracted = extract_serial_numbers(serials, quantity)
except ValidationError as e:
extracted = None
form.add_error('serial_numbers', e.messages)
@@ -912,9 +1023,14 @@ class BuildAttachmentCreate(AjaxCreateView):
ajax_form_title = _('Add Build Order Attachment')
role_required = 'build.add'
- def post_save(self, **kwargs):
- self.object.user = self.request.user
- self.object.save()
+ def save(self, form, **kwargs):
+ """
+ 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):
return {
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 2db5b44c4b..d0e838a08b 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -100,7 +100,9 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
ajax_template_name = "modal_form.html"
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.save()
@@ -148,9 +150,14 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
ajax_form_title = _('Add Sales Order Attachment')
role_required = 'sales_order.add'
- def post_save(self, attachment, form, **kwargs):
- self.object.user = self.request.user
- self.object.save()
+ def save(self, form, **kwargs):
+ """
+ Save the user that uploaded the attachment
+ """
+
+ attachment = form.save(commit=False)
+ attachment.user = self.request.user
+ attachment.save()
def get_data(self):
return {
@@ -295,7 +302,9 @@ class SalesOrderNotes(InvenTreeRoleMixin, UpdateView):
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
ajax_form_title = _("Create Purchase Order")
@@ -319,9 +328,12 @@ class PurchaseOrderCreate(AjaxCreateView):
return initials
- def post_save(self, order, form, **kwargs):
- # Record the user who created this purchase order
+ def save(self, form, **kwargs):
+ """
+ Record the user who created this PurchaseOrder
+ """
+ order = form.save(commit=False)
order.created_by = self.request.user
order.save()
@@ -351,8 +363,12 @@ class SalesOrderCreate(AjaxCreateView):
return initials
- def post_save(self, order, form, **kwargs):
- # Record the user who created this sales order
+ def save(self, form, **kwargs):
+ """
+ Record the user who created this SalesOrder
+ """
+
+ order = form.save(commit=False)
order.created_by = self.request.user
order.save()
@@ -414,7 +430,10 @@ class PurchaseOrderCancel(AjaxUpdateView):
if not order.can_cancel():
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()
@@ -438,7 +457,10 @@ class SalesOrderCancel(AjaxUpdateView):
if not order.can_cancel():
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()
@@ -459,8 +481,10 @@ class PurchaseOrderIssue(AjaxUpdateView):
if not confirm:
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()
def get_data(self):
@@ -495,7 +519,10 @@ class PurchaseOrderComplete(AjaxUpdateView):
if not confirm:
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()
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 4544a09b66..6a64f595b5 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -905,6 +905,7 @@ class Part(MPTTModel):
def has_bom(self):
return self.bom_count > 0
+ @property
def has_trackable_parts(self):
"""
Return True if any parts linked in the Bill of Materials are trackable.
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 7eb7ff3c65..f88f7f415a 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -82,10 +82,14 @@ class PartAttachmentCreate(AjaxCreateView):
role_required = 'part.add'
- def post_save(self):
- """ Record the user that uploaded the attachment """
- self.object.user = self.request.user
- self.object.save()
+ def save(self, form, **kwargs):
+ """
+ Record the user that uploaded this attachment
+ """
+
+ attachment = form.save(commit=False)
+ attachment.user = self.request.user
+ attachment.save()
def get_data(self):
return {
@@ -874,7 +878,10 @@ class BomDuplicate(AjaxUpdateView):
if not confirm:
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)
@@ -915,7 +922,10 @@ class BomValidate(AjaxUpdateView):
if not confirm:
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)
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index b25022fbdc..2e45ceb00e 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -21,7 +21,7 @@ from InvenTree.views import InvenTreeRoleMixin
from InvenTree.forms import ConfirmForm
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 datetime import datetime
@@ -164,9 +164,10 @@ class StockItemAttachmentCreate(AjaxCreateView):
ajax_template_name = "modal_form.html"
role_required = 'stock.add'
- def post_save(self, attachment, form, **kwargs):
+ def save(self, form, **kwargs):
""" Record the user that uploaded the attachment """
+ attachment = form.save(commit=False)
attachment.user = self.request.user
attachment.save()
@@ -252,7 +253,7 @@ class StockItemAssignToCustomer(AjaxUpdateView):
if not customer:
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.
"""
@@ -286,7 +287,7 @@ class StockItemReturnToStock(AjaxUpdateView):
if not 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)
@@ -431,9 +432,12 @@ class StockItemTestResultCreate(AjaxCreateView):
ajax_form_title = _("Add Test Result")
role_required = 'stock.add'
- def post_save(self, result, form, **kwargs):
- """ Record the user that uploaded the test result """
+ def save(self, form, **kwargs):
+ """
+ Record the user that uploaded the test result
+ """
+ result = form.save(commit=False)
result.user = self.request.user
result.save()
@@ -1405,7 +1409,7 @@ class StockItemSerialize(AjaxUpdateView):
destination = None
try:
- numbers = ExtractSerialNumbers(serials, quantity)
+ numbers = extract_serial_numbers(serials, quantity)
except ValidationError as e:
form.add_error('serial_numbers', e.messages)
valid = False
@@ -1630,7 +1634,7 @@ class StockItemCreate(AjaxCreateView):
# If user has specified a range of serial numbers
if len(sn) > 0:
try:
- serials = ExtractSerialNumbers(sn, quantity)
+ serials = extract_serial_numbers(sn, quantity)
existing = part.find_conflicting_serial_numbers(serials)
diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js
index d6f3681036..76e6093dc8 100644
--- a/InvenTree/templates/js/build.js
+++ b/InvenTree/templates/js/build.js
@@ -92,14 +92,14 @@ function makeBuildOutputActionButtons(output, buildInfo) {
data: {
output: outputId,
},
- success: reloadTable,
+ reload: true,
}
);
});
$(panel).find(`#button-output-complete-${outputId}`).click(function() {
launchModalForm(
- `/build/${buildId}/complete/`,
+ `/build/${buildId}/complete-output/`,
{
success: reloadTable,
data: {