mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
b02c87ea50
commit
500da8099b
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
@ -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:
|
||||||
|
@ -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: {
|
||||||
|
20
InvenTree/build/templates/build/build_output_create.html
Normal file
20
InvenTree/build/templates/build/build_output_create.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block pre_form_content %}
|
||||||
|
|
||||||
|
{% if build.part.has_trackable_parts %}
|
||||||
|
<div class='alert alert-block alert-warning'>
|
||||||
|
{% trans "The Bill of Materials contains trackable parts" %}<br>
|
||||||
|
{% trans "Build outputs must be generated individually." %}<br>
|
||||||
|
{% trans "Multiple build outputs will be created based on the quantity specified." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if build.part.trackable %}
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
{% trans "Trackable parts can have serial numbers specified" %}<br>
|
||||||
|
{% trans "Enter serial numbers to generate multiple single build outputs" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -1,7 +1,7 @@
|
|||||||
{% extends "modal_form.html" %}
|
{% extends "modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
Are you sure you wish to cancel this build?
|
{% trans "Are you sure you wish to cancel this build?" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -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 %}
|
48
InvenTree/build/templates/build/complete_output.html
Normal file
48
InvenTree/build/templates/build/complete_output.html
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{% extends "modal_form.html" %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block pre_form_content %}
|
||||||
|
|
||||||
|
{% if fully_allocated %}
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
<h4>{% trans "Stock allocation is complete" %}</h4>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class='alert alert-block alert-danger'>
|
||||||
|
<h4>{% trans "Stock allocation is incomplete" %}</h4>
|
||||||
|
|
||||||
|
<div class='panel-group'>
|
||||||
|
<div class='panel panel-default'>
|
||||||
|
<div class='panel panel-heading'>
|
||||||
|
<a data-toggle='collapse' href='#collapse-unallocated'>
|
||||||
|
{{ unallocated_parts|length }} {% trans "parts have not been fully allocated" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class='panel-collapse collapse' id='collapse-unallocated'>
|
||||||
|
<div class='panel-body'>
|
||||||
|
<ul class='list-group'>
|
||||||
|
{% for part in unallocated_parts %}
|
||||||
|
<li class='list-group-item'>
|
||||||
|
{% include "hover_image.html" with image=part.image %} {{ part }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class='panel panel-info'>
|
||||||
|
<div class='panel-heading'>
|
||||||
|
{% trans "The following items will be created" %}
|
||||||
|
</div>
|
||||||
|
<div class='panel-content'>
|
||||||
|
{% include "hover_image.html" with image=build.part.image hover=True %}
|
||||||
|
{% decimal output.quantity %} x {{ output.part.full_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -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)
|
||||||
|
|
||||||
|
@ -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'),
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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: {
|
||||||
|
Loading…
Reference in New Issue
Block a user