mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 04:55:44 +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:
@ -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',
|
||||
|
@ -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:
|
||||
|
@ -11,11 +11,19 @@ InvenTree | Allocate Parts
|
||||
|
||||
{% include "build/tabs.html" with tab='allocate' %}
|
||||
|
||||
<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>
|
||||
|
||||
<h4>{% trans "Incomplete Build Ouputs" %}</h4>
|
||||
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
|
||||
{% for item in build.incomplete_outputs %}
|
||||
{% include "build/allocation_card.html" with item=item %}
|
||||
@ -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 = `<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 %}
|
||||
$("#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/", {
|
||||
|
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" %}
|
||||
|
||||
{% 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 %}
|
@ -1,48 +1,23 @@
|
||||
{% 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>
|
||||
{% if build.can_complete %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Build can be completed" %}
|
||||
</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>
|
||||
<b>{% trans "Build cannot be completed" %}</b><br>
|
||||
<ul>
|
||||
{% if build.incomplete_count > 0 %}
|
||||
<li>{% trans "Incompleted build outputs remain" %}</li>
|
||||
{% endif %}
|
||||
{% if build.completed < build.quantity %}
|
||||
<li>{% trans "Required build quantity has not been completed" %}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</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 %}
|
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 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)
|
||||
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -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 {
|
||||
|
Reference in New Issue
Block a user