Merge pull request #1492 from SchrodingersGat/build-order-simplification

Build order simplification
This commit is contained in:
Oliver 2021-04-21 17:29:24 +10:00 committed by GitHub
commit cfddaadccf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 3332 additions and 2433 deletions

View File

@ -62,6 +62,14 @@ class StatusCode:
def items(cls): def items(cls):
return cls.options.items() return cls.options.items()
@classmethod
def keys(cls):
return cls.options.keys()
@classmethod
def labels(cls):
return cls.options.values()
@classmethod @classmethod
def label(cls, value): def label(cls, value):
""" Return the status code label associated with the provided value """ """ Return the status code label associated with the provided value """

View File

@ -11,7 +11,7 @@ from rest_framework import generics
from django.conf.urls import url, include from django.conf.urls import url, include
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool, isNull
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem from .models import Build, BuildItem
@ -194,6 +194,10 @@ class BuildItemList(generics.ListCreateAPIView):
output = params.get('output', None) output = params.get('output', None)
if output: if output:
if isNull(output):
queryset = queryset.filter(install_into=None)
else:
queryset = queryset.filter(install_into=output) queryset = queryset.filter(install_into=output)
return queryset return queryset

View File

@ -12,6 +12,8 @@ from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField from InvenTree.fields import DatePickerFormField
from InvenTree.status_codes import StockStatus
from .models import Build, BuildItem, BuildOrderAttachment from .models import Build, BuildItem, BuildOrderAttachment
from stock.models import StockLocation, StockItem from stock.models import StockLocation, StockItem
@ -165,16 +167,10 @@ class AutoAllocateForm(HelperForm):
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation')) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation'))
# Keep track of which build output we are interested in
output = forms.ModelChoiceField(
queryset=StockItem.objects.all(),
)
class Meta: class Meta:
model = Build model = Build
fields = [ fields = [
'confirm', 'confirm',
'output',
] ]
@ -214,6 +210,13 @@ class CompleteBuildOutputForm(HelperForm):
help_text=_('Location of completed parts'), help_text=_('Location of completed parts'),
) )
stock_status = forms.ChoiceField(
label=_('Status'),
help_text=_('Build output stock status'),
initial=StockStatus.OK,
choices=StockStatus.items(),
)
confirm_incomplete = forms.BooleanField( confirm_incomplete = forms.BooleanField(
required=False, required=False,
label=_('Confirm incomplete'), label=_('Confirm incomplete'),
@ -232,10 +235,15 @@ class CompleteBuildOutputForm(HelperForm):
fields = [ fields = [
'location', 'location',
'output', 'output',
'stock_status',
'confirm', 'confirm',
'confirm_incomplete', 'confirm_incomplete',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class CancelBuildForm(HelperForm): class CancelBuildForm(HelperForm):
""" Form for cancelling a build """ """ Form for cancelling a build """

View File

@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.validators import validate_build_order_reference from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment from InvenTree.models import InvenTreeAttachment
@ -314,6 +314,42 @@ class Build(MPTTModel):
'sub_part' 'sub_part'
) )
@property
def tracked_bom_items(self):
"""
Returns the "trackable" BOM items for this BuildOrder
"""
items = self.bom_items
items = items.filter(sub_part__trackable=True)
return items
def has_tracked_bom_items(self):
"""
Returns True if this BuildOrder has trackable BomItems
"""
return self.tracked_bom_items.count() > 0
@property
def untracked_bom_items(self):
"""
Returns the "non trackable" BOM items for this BuildOrder
"""
items = self.bom_items
items = items.filter(sub_part__trackable=False)
return items
def has_untracked_bom_items(self):
"""
Returns True if this BuildOrder has non trackable BomItems
"""
return self.untracked_bom_items.count() > 0
@property @property
def remaining(self): def remaining(self):
""" """
@ -449,6 +485,9 @@ class Build(MPTTModel):
if self.completed < self.quantity: if self.completed < self.quantity:
return False return False
if not self.areUntrackedPartsFullyAllocated():
return False
# No issues! # No issues!
return True return True
@ -458,7 +497,7 @@ class Build(MPTTModel):
Mark this build as complete Mark this build as complete
""" """
if not self.can_complete: if self.incomplete_count > 0:
return return
self.completion_date = datetime.now().date() self.completion_date = datetime.now().date()
@ -466,6 +505,9 @@ class Build(MPTTModel):
self.status = BuildStatus.COMPLETE self.status = BuildStatus.COMPLETE
self.save() self.save()
# Remove untracked allocated stock
self.subtractUntrackedStock(user)
# Ensure that there are no longer any BuildItem objects # Ensure that there are no longer any BuildItem objects
# which point to thie Build Order # which point to thie Build Order
self.allocated_stock.all().delete() self.allocated_stock.all().delete()
@ -489,7 +531,7 @@ class Build(MPTTModel):
self.status = BuildStatus.CANCELLED self.status = BuildStatus.CANCELLED
self.save() self.save()
def getAutoAllocations(self, output): def getAutoAllocations(self):
""" """
Return a list of StockItem objects which will be allocated Return a list of StockItem objects which will be allocated
using the 'AutoAllocate' function. using the 'AutoAllocate' function.
@ -521,15 +563,19 @@ class Build(MPTTModel):
part = bom_item.sub_part part = bom_item.sub_part
# If the part is "trackable" it cannot be auto-allocated
if part.trackable:
continue
# Skip any parts which are already fully allocated # Skip any parts which are already fully allocated
if self.isPartFullyAllocated(part, output): if self.isPartFullyAllocated(part, None):
continue continue
# How many parts are required to complete the output? # How many parts are required to complete the output?
required = self.unallocatedQuantity(part, output) required = self.unallocatedQuantity(part, None)
# Grab a list of stock items which are available # Grab a list of stock items which are available
stock_items = self.availableStockItems(part, output) stock_items = self.availableStockItems(part, None)
# Ensure that the available stock items are in the correct location # Ensure that the available stock items are in the correct location
if self.take_from is not None: if self.take_from is not None:
@ -544,7 +590,6 @@ class Build(MPTTModel):
build_items = BuildItem.objects.filter( build_items = BuildItem.objects.filter(
build=self, build=self,
stock_item=stock_item, stock_item=stock_item,
install_into=output
) )
if len(build_items) > 0: if len(build_items) > 0:
@ -567,24 +612,45 @@ class Build(MPTTModel):
return allocations return allocations
@transaction.atomic @transaction.atomic
def unallocateStock(self, output=None, part=None): def unallocateOutput(self, output, part=None):
""" """
Deletes all stock allocations for this build. Unallocate all stock which are allocated against the provided "output" (StockItem)
Args:
output: Specify which build output to delete allocations (optional)
""" """
allocations = BuildItem.objects.filter(build=self.pk) allocations = BuildItem.objects.filter(
build=self,
if output: install_into=output
allocations = allocations.filter(install_into=output.pk) )
if part: if part:
allocations = allocations.filter(stock_item__part=part) allocations = allocations.filter(stock_item__part=part)
# Remove all the allocations allocations.delete()
@transaction.atomic
def unallocateUntracked(self, part=None):
"""
Unallocate all "untracked" stock
"""
allocations = BuildItem.objects.filter(
build=self,
install_into=None
)
if part:
allocations = allocations.filter(stock_item__part=part)
allocations.delete()
@transaction.atomic
def unallocateAll(self):
"""
Deletes all stock allocations for this build.
"""
allocations = BuildItem.objects.filter(build=self)
allocations.delete() allocations.delete()
@transaction.atomic @transaction.atomic
@ -679,13 +745,13 @@ class Build(MPTTModel):
raise ValidationError(_("Build output does not match Build Order")) raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output # Unallocate all build items against the output
self.unallocateStock(output) self.unallocateOutput(output)
# Remove the build output from the database # Remove the build output from the database
output.delete() output.delete()
@transaction.atomic @transaction.atomic
def autoAllocate(self, output): def autoAllocate(self):
""" """
Run auto-allocation routine to allocate StockItems to this Build. Run auto-allocation routine to allocate StockItems to this Build.
@ -702,7 +768,7 @@ class Build(MPTTModel):
See: getAutoAllocations() See: getAutoAllocations()
""" """
allocations = self.getAutoAllocations(output) allocations = self.getAutoAllocations()
for item in allocations: for item in allocations:
# Create a new allocation # Create a new allocation
@ -710,11 +776,29 @@ class Build(MPTTModel):
build=self, build=self,
stock_item=item['stock_item'], stock_item=item['stock_item'],
quantity=item['quantity'], quantity=item['quantity'],
install_into=output, install_into=None
) )
build_item.save() build_item.save()
@transaction.atomic
def subtractUntrackedStock(self, user):
"""
Called when the Build is marked as "complete",
this function removes the allocated untracked items from stock.
"""
items = self.allocated_stock.filter(
stock_item__part__trackable=False
)
# Remove stock
for item in items:
item.complete_allocation(user)
# Delete allocation
items.all().delete()
@transaction.atomic @transaction.atomic
def completeBuildOutput(self, output, user, **kwargs): def completeBuildOutput(self, output, user, **kwargs):
""" """
@ -726,6 +810,7 @@ class Build(MPTTModel):
# Select the location for the build output # Select the location for the build output
location = kwargs.get('location', self.destination) location = kwargs.get('location', self.destination)
status = kwargs.get('status', StockStatus.OK)
# List the allocated BuildItem objects for the given output # List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all() allocated_items = output.items_to_install.all()
@ -733,9 +818,7 @@ class Build(MPTTModel):
for build_item in allocated_items: for build_item in allocated_items:
# TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete # TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete
# TODO: Use celery / redis to offload the actual object deletion... # TODO: Use the background worker process to handle this task!
# REF: https://www.botreetechnologies.com/blog/implementing-celery-using-django-for-background-task-processing
# REF: https://code.tutsplus.com/tutorials/using-celery-with-django-for-background-task-processing--cms-28732
# Complete the allocation of stock for that item # Complete the allocation of stock for that item
build_item.complete_allocation(user) build_item.complete_allocation(user)
@ -747,6 +830,7 @@ class Build(MPTTModel):
output.build = self output.build = self
output.is_building = False output.is_building = False
output.location = location output.location = location
output.status = status
output.save() output.save()
@ -779,7 +863,7 @@ class Build(MPTTModel):
if output: if output:
quantity *= output.quantity quantity *= output.quantity
else: else:
quantity *= self.remaining quantity *= self.quantity
return quantity return quantity
@ -834,19 +918,39 @@ class Build(MPTTModel):
return self.unallocatedQuantity(part, output) == 0 return self.unallocatedQuantity(part, output) == 0
def isFullyAllocated(self, output): def isFullyAllocated(self, output, verbose=False):
""" """
Returns True if the particular build output is fully allocated. Returns True if the particular build output is fully allocated.
""" """
for bom_item in self.bom_items: # If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
fully_allocated = True
for bom_item in bom_items:
part = bom_item.sub_part part = bom_item.sub_part
if not self.isPartFullyAllocated(part, output): if not self.isPartFullyAllocated(part, output):
return False fully_allocated = False
if verbose:
print(f"Part {part} is not fully allocated for output {output}")
else:
break
# All parts must be fully allocated! # All parts must be fully allocated!
return True return fully_allocated
def areUntrackedPartsFullyAllocated(self):
"""
Returns True if the un-tracked parts are fully allocated for this BuildOrder
"""
return self.isFullyAllocated(None)
def allocatedParts(self, output): def allocatedParts(self, output):
""" """
@ -855,7 +959,13 @@ class Build(MPTTModel):
allocated = [] allocated = []
for bom_item in self.bom_items: # If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
part = bom_item.sub_part part = bom_item.sub_part
if self.isPartFullyAllocated(part, output): if self.isPartFullyAllocated(part, output):
@ -870,7 +980,13 @@ class Build(MPTTModel):
unallocated = [] unallocated = []
for bom_item in self.bom_items: # If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
part = bom_item.sub_part part = bom_item.sub_part
if not self.isPartFullyAllocated(part, output): if not self.isPartFullyAllocated(part, output):
@ -1020,10 +1136,12 @@ class BuildItem(models.Model):
errors = {} errors = {}
if not self.install_into:
raise ValidationError(_('Build item must specify a build output'))
try: try:
# If the 'part' is trackable, then the 'install_into' field must be set!
if self.stock_item.part and self.stock_item.part.trackable and not self.install_into:
raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable'))
# Allocated part must be in the BOM for the master part # Allocated part must be in the BOM for the master part
if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False): if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False):
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)] errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)]

View File

@ -12,48 +12,41 @@
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}
{% trans "Incomplete Build Ouputs" %} {% trans "Allocate Stock to Build" %}
{% endblock %} {% endblock %}
{% block details %} {% block details %}
{% if build.is_complete %} {% if build.has_untracked_bom_items %}
<div class='alert alert-block alert-success'> {% if build.active %}
{% trans "Build order has been completed" %}
</div>
{% else %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if build.active %} <button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'> <span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %} </button>
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
</button> </button>
<!-- <!--
<button class='btn btn-primary' type='button' id='btn-order-parts' title='{% trans "Order required parts" %}'> <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" %} <span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</button> </button>
--> -->
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
</button>
{% endif %}
</div> </div>
{% if build.areUntrackedPartsFullyAllocated %}
<hr> <div class='alert alert-block alert-success'>
{% if build.incomplete_outputs %} {% trans "Untracked stock has been fully allocated for this Build Order" %}
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
{% for item in build.incomplete_outputs %}
{% include "build/allocation_card.html" with item=item %}
{% endfor %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-danger'>
<b>{% trans "Create a new build output" %}</b><br> {% trans "Untracked stock has not been fully allocated for this Build Order" %}
{% trans "No incomplete build outputs remain." %}<br>
{% trans "Create a new build output using the button above" %}
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<table class='table table-striped table-condensed' id='allocation-table-untracked'></table>
{% else %}
<div class='alert alert-block alert-info'>
{% trans "This Build Order does not have any associated untracked BOM items" %}
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
@ -66,19 +59,17 @@
part: {{ build.part.pk }}, part: {{ build.part.pk }},
}; };
{% for item in build.incomplete_outputs %} {% if build.has_untracked_bom_items %}
// Get the build output as a javascript object // Load allocation table for un-tracked parts
inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, loadBuildOutputAllocationTable(buildInfo, null);
{ {% endif %}
success: function(response) {
loadBuildOutputAllocationTable(buildInfo, response); function reloadTable() {
$('#allocation-table-untracked').bootstrapTable('refresh');
} }
}
);
{% endfor %}
{% if build.active %} {% if build.active %}
$("#btn-allocate").on('click', function() { $("#btn-auto-allocate").on('click', function() {
launchModalForm( launchModalForm(
"{% url 'build-auto-allocate' build.id %}", "{% url 'build-auto-allocate' build.id %}",
{ {
@ -91,15 +82,7 @@
launchModalForm( launchModalForm(
"{% url 'build-unallocate' build.id %}", "{% url 'build-unallocate' build.id %}",
{ {
reload: true, success: reloadTable,
}
);
});
$('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
{
reload: true,
} }
); );
}); });

View File

@ -7,23 +7,31 @@
<div class="panel-heading" role="tab" id="heading-{{ pk }}"> <div class="panel-heading" role="tab" id="heading-{{ pk }}">
<div class="panel-title"> <div class="panel-title">
<div class='row'> <div class='row'>
{% if tracked_items %}
<a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}"> <a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}">
{% endif %}
<div class='col-sm-4'> <div class='col-sm-4'>
{% if tracked_items %}
<span class='fas fa-caret-right'></span> <span class='fas fa-caret-right'></span>
{% endif %}
{{ item.part.full_name }} {{ item.part.full_name }}
</div> </div>
<div class='col-sm-2'> <div class='col-sm-2'>
{% if item.serial %} {% if item.serial %}
# {{ item.serial }} {% trans "Serial Number" %}: {{ item.serial }}
{% else %} {% else %}
{% decimal item.quantity %} {% trans "Quantity" %}: {% decimal item.quantity %}
{% endif %} {% endif %}
</div> </div>
{% if tracked_items %}
</a> </a>
{% endif %}
<div class='col-sm-3'> <div class='col-sm-3'>
<div> <div>
<div id='output-progress-{{ pk }}'> <div id='output-progress-{{ pk }}'>
{% if tracked_items %}
<span class='fas fa-spin fa-spinner'></span> <span class='fas fa-spin fa-spinner'></span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,7 +9,7 @@
{% inventree_title %} | {% trans "Build Order" %} - {{ build }} {% inventree_title %} | {% trans "Build Order" %} - {{ build }}
{% endblock %} {% endblock %}
{% block pre_content %} {% block header_pre_content %}
{% if build.sales_order %} {% if build.sales_order %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% object_link 'so-detail' build.sales_order.id build.sales_order as link %} {% object_link 'so-detail' build.sales_order.id build.sales_order as link %}
@ -24,6 +24,31 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block header_post_content %}
{% if build.active %}
{% if build.can_complete %}
<div class='alert alert-block alert-success'>
{% trans "Build Order is ready to mark as completed" %}
</div>
{% endif %}
{% if build.incomplete_count > 0 %}
<div class='alert alert-block alert-danger'>
{% trans "Build Order cannot be completed as outstanding outputs remain" %}
</div>
{% endif %}
{% if build.completed < build.quantity %}
<div class='alert alert-block alert-warning'>
{% trans "Required build quantity has not yet been completed" %}
</div>
{% endif %}
{% if not build.areUntrackedPartsFullyAllocated %}
<div class='alert alert-block alert-warning'>
{% trans "Stock has not been fully allocated to this Build Order" %}
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block thumbnail %} {% block thumbnail %}
<img class="part-thumb" <img class="part-thumb"
{% if build.part.image %} {% if build.part.image %}
@ -61,6 +86,11 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
<!-- Build actions --> <!-- Build actions -->
{% if roles.build.change %} {% if roles.build.change %}
{% if build.active %}
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
<span class='fas fa-paper-plane'></span>
</button>
{% endif %}
<div class='btn-group'> <div class='btn-group'>
<button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> <button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-tools'></span> <span class='caret'></span> <span class='fas fa-tools'></span> <span class='caret'></span>
@ -68,7 +98,6 @@ src="{% static 'img/blank_image.png' %}"
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li> <li><a href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li>
{% if build.is_active %} {% if build.is_active %}
<li><a href='#' id='build-complete'><span class='fas fa-tools'></span> {% trans "Complete Build" %}</a></li>
<li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li> <li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %} {% endif %}
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %} {% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
@ -172,6 +201,13 @@ src="{% static 'img/blank_image.png' %}"
}); });
$("#build-complete").on('click', function() { $("#build-complete").on('click', function() {
{% if build.incomplete_count > 0 %}
showAlertDialog(
'{% trans "Incomplete Outputs" %}',
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}'
);
{% else %}
launchModalForm( launchModalForm(
"{% url 'build-complete' build.id %}", "{% url 'build-complete' build.id %}",
{ {
@ -179,6 +215,7 @@ src="{% static 'img/blank_image.png' %}"
submit_text: '{% trans "Complete Build" %}', submit_text: '{% trans "Complete Build" %}',
} }
); );
{% endif %}
}); });
$('#print-build-report').click(function() { $('#print-build-report').click(function() {

View File

@ -6,19 +6,68 @@
{% include "build/navbar.html" with tab='output' %} {% include "build/navbar.html" with tab='output' %}
{% endblock %} {% endblock %}
{% block heading %} {% block content_panels %}
{% trans "Build Outputs" %}
{% endblock %}
{% block details %} {% if not build.is_complete %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>
{% trans "Incomplete Build Outputs" %}
</h4>
</div>
{% include "stock_table.html" with read_only=True %} <div class='panel-content'>
<div class='btn-group' role='group'>
{% if build.active %}
<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>
{% endif %}
</div>
{% if build.incomplete_outputs %}
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
{% for item in build.incomplete_outputs %}
{% include "build/allocation_card.html" with item=item tracked_items=build.has_tracked_bom_items %}
{% endfor %}
</div>
{% else %}
<div class='alert alert-block alert-info'>
<b>{% trans "Create a new build output" %}</b><br>
{% trans "No incomplete build outputs remain." %}<br>
{% trans "Create a new build output using the button above" %}
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>
{% trans "Completed Build Outputs" %}
</h4>
</div>
<div class='panel-content'>
{% include "stock_table.html" with read_only=True %}
</div>
</div>
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
{
reload: true,
}
);
});
loadStockTable($("#stock-table"), { loadStockTable($("#stock-table"), {
params: { params: {
location_detail: true, location_detail: true,
@ -32,4 +81,23 @@ loadStockTable($("#stock-table"), {
url: "{% url 'api-stock-list' %}", url: "{% url 'api-stock-list' %}",
}); });
var buildInfo = {
pk: {{ build.pk }},
quantity: {{ build.quantity }},
completed: {{ build.completed }},
part: {{ build.part.pk }},
};
{% for item in build.incomplete_outputs %}
// Get the build output as a javascript object
inventreeGet('{% url 'api-stock-detail' item.pk %}', {},
{
success: function(response) {
loadBuildOutputAllocationTable(buildInfo, response);
}
}
);
{% endfor %}
{% endblock %} {% endblock %}

View File

@ -5,11 +5,11 @@
{% if build.can_complete %} {% if build.can_complete %}
<div class='alert alert-block alert-success'> <div class='alert alert-block alert-success'>
{% trans "Build can be completed" %} {% trans "Build Order is complete" %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
<b>{% trans "Build cannot be completed" %}</b><br> <b>{% trans "Build Order is incomplete" %}</b><br>
<ul> <ul>
{% if build.incomplete_count > 0 %} {% if build.incomplete_count > 0 %}
<li>{% trans "Incompleted build outputs remain" %}</li> <li>{% trans "Incompleted build outputs remain" %}</li>
@ -17,6 +17,9 @@
{% if build.completed < build.quantity %} {% if build.completed < build.quantity %}
<li>{% trans "Required build quantity has not been completed" %}</li> <li>{% trans "Required build quantity has not been completed" %}</li>
{% endif %} {% endif %}
{% if not build.areUntrackedPartsFullyAllocated %}
<li>{% trans "Required stock has not been fully allocated" %}</li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@ -4,9 +4,10 @@
{% block pre_form_content %} {% block pre_form_content %}
{% if fully_allocated %} {% if not build.has_tracked_bom_items %}
<div class='alert alert-block alert-info'> {% elif fully_allocated %}
<h4>{% trans "Stock allocation is complete" %}</h4> <div class='alert alert-block alert-success'>
{% trans "Stock allocation is complete for this output" %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
@ -16,7 +17,7 @@
<div class='panel panel-default'> <div class='panel panel-default'>
<div class='panel panel-heading'> <div class='panel panel-heading'>
<a data-toggle='collapse' href='#collapse-unallocated'> <a data-toggle='collapse' href='#collapse-unallocated'>
{{ unallocated_parts|length }} {% trans "parts have not been fully allocated" %} {{ unallocated_parts|length }} {% trans "tracked parts have not been fully allocated" %}
</a> </a>
</div> </div>
<div class='panel-collapse collapse' id='collapse-unallocated'> <div class='panel-collapse collapse' id='collapse-unallocated'>
@ -41,7 +42,11 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "hover_image.html" with image=build.part.image hover=True %} {% include "hover_image.html" with image=build.part.image hover=True %}
{% if output.serialized %}
{{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }}
{% else %}
{% decimal output.quantity %} x {{ output.part.full_name }} {% decimal output.quantity %} x {{ output.part.full_name }}
{% endif %}
</div> </div>
</div> </div>

View File

@ -17,17 +17,11 @@
</li> </li>
{% if build.active %} {% if build.active %}
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Required Parts" %}'>
<a href='{% url "build-parts" build.id %}'>
<span class='fas fa-shapes'></span>
{% trans "Required Parts" %}
</a>
</li>
<li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "In Progress" %}'> <li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "Allocate Stock" %}'>
<a href='{% url "build-allocate" build.id %}'> <a href='{% url "build-allocate" build.id %}'>
<span class='fas fa-tools'></span> <span class='fas fa-tools'></span>
{% trans "In Progress" %} {% trans "Allocate Stock" %}
</a> </a>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,30 +0,0 @@
{% extends "build/build_base.html" %}
{% load static %}
{% load i18n %}
{% load status_codes %}
{% block menubar %}
{% include "build/navbar.html" with tab='parts' %}
{% endblock %}
{% block heading %}
{% trans "Required Parts" %}
{% endblock %}
{% block details %}
<table class='table table-striped table-condensed' id='parts-table'></table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadBuildPartsTable($('#parts-table'), {
part: {{ build.part.pk }},
build: {{ build.pk }},
build_quantity: {{ build.quantity }},
build_remaining: {{ build.remaining }},
});
{% endblock %}

View File

@ -19,6 +19,18 @@ class BuildTest(TestCase):
def setUp(self): def setUp(self):
""" """
Initialize data to use for these tests. Initialize data to use for these tests.
The base Part 'assembly' has a BOM consisting of three parts:
- 5 x sub_part_1
- 3 x sub_part_2
- 2 x sub_part_3 (trackable)
We will build 10x 'assembly' parts, in two build outputs:
- 3 x output_1
- 7 x output_2
""" """
# Create a base "Part" # Create a base "Part"
@ -41,17 +53,31 @@ class BuildTest(TestCase):
component=True component=True
) )
self.sub_part_3 = Part.objects.create(
name="Widget C",
description="A widget",
component=True,
trackable=True
)
# Create BOM item links for the parts # Create BOM item links for the parts
BomItem.objects.create( BomItem.objects.create(
part=self.assembly, part=self.assembly,
sub_part=self.sub_part_1, sub_part=self.sub_part_1,
quantity=10 quantity=5
) )
BomItem.objects.create( BomItem.objects.create(
part=self.assembly, part=self.assembly,
sub_part=self.sub_part_2, sub_part=self.sub_part_2,
quantity=25 quantity=3
)
# sub_part_3 is trackable!
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_3,
quantity=2
) )
# Create a "Build" object to make 10x objects # Create a "Build" object to make 10x objects
@ -64,14 +90,14 @@ class BuildTest(TestCase):
# Create some build output (StockItem) objects # Create some build output (StockItem) objects
self.output_1 = StockItem.objects.create( self.output_1 = StockItem.objects.create(
part=self.assembly, part=self.assembly,
quantity=5, quantity=3,
is_building=True, is_building=True,
build=self.build build=self.build
) )
self.output_2 = StockItem.objects.create( self.output_2 = StockItem.objects.create(
part=self.assembly, part=self.assembly,
quantity=5, quantity=7,
is_building=True, is_building=True,
build=self.build, build=self.build,
) )
@ -82,10 +108,12 @@ class BuildTest(TestCase):
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000) self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000)
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
def test_init(self): def test_init(self):
# Perform some basic tests before we start the ball rolling # Perform some basic tests before we start the ball rolling
self.assertEqual(StockItem.objects.count(), 5) self.assertEqual(StockItem.objects.count(), 6)
# Build is PENDING # Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.PENDING) self.assertEqual(self.build.status, status.BuildStatus.PENDING)
@ -100,10 +128,10 @@ class BuildTest(TestCase):
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1)) self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2)) self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 50) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 15)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 50) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 35)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 125) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 9)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 125) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 21)
self.assertFalse(self.build.is_complete) self.assertFalse(self.build.is_complete)
@ -144,84 +172,113 @@ class BuildTest(TestCase):
quantity=99 quantity=99
) )
def allocate_stock(self, q11, q12, q21, output): def allocate_stock(self, output, allocations):
# Assign stock to this build """
Allocate stock to this build, against a particular output
if q11 > 0: Args:
output - StockItem object (or None)
allocations - Map of {StockItem: quantity}
"""
for item, quantity in allocations.items():
BuildItem.objects.create( BuildItem.objects.create(
build=self.build, build=self.build,
stock_item=self.stock_1_1, stock_item=item,
quantity=q11, quantity=quantity,
install_into=output install_into=output
) )
if q12 > 0:
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_1_2,
quantity=q12,
install_into=output
)
if q21 > 0:
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_2_1,
quantity=q21,
install_into=output,
)
# Attempt to create another identical BuildItem
b = BuildItem(
build=self.build,
stock_item=self.stock_2_1,
quantity=q21
)
with self.assertRaises(ValidationError):
b.clean()
def test_partial_allocation(self): def test_partial_allocation(self):
""" """
Partially allocate against output 1 Test partial allocation of stock
""" """
self.allocate_stock(50, 50, 200, self.output_1) # Fully allocate tracked stock against build output 1
self.allocate_stock(
self.output_1,
{
self.stock_3_1: 6,
}
)
self.assertTrue(self.build.isFullyAllocated(self.output_1)) self.assertTrue(self.build.isFullyAllocated(self.output_1))
# Partially allocate tracked stock against build output 2
self.allocate_stock(
self.output_2,
{
self.stock_3_1: 1,
}
)
self.assertFalse(self.build.isFullyAllocated(self.output_2)) self.assertFalse(self.build.isFullyAllocated(self.output_2))
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_2)) # Partially allocate untracked stock against build
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2)) self.allocate_stock(
None,
{
self.stock_1_1: 1,
self.stock_2_1: 1
}
)
# Check that the part has been allocated self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 100)
self.build.unallocateStock(output=self.output_1) unallocated = self.build.unallocatedParts(None)
self.assertEqual(BuildItem.objects.count(), 0)
# Check that the part has been unallocated self.assertEqual(len(unallocated), 2)
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 0)
self.allocate_stock(
None,
{
self.stock_1_2: 100,
}
)
self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
unallocated = self.build.unallocatedParts(None)
self.assertEqual(len(unallocated), 1)
self.build.unallocateUntracked()
unallocated = self.build.unallocatedParts(None)
self.assertEqual(len(unallocated), 2)
self.assertFalse(self.build.areUntrackedPartsFullyAllocated())
# Now we "fully" allocate the untracked untracked items
self.allocate_stock(
None,
{
self.stock_1_1: 50,
self.stock_2_1: 50,
}
)
self.assertTrue(self.build.areUntrackedPartsFullyAllocated())
def test_auto_allocate(self): def test_auto_allocate(self):
""" """
Test auto-allocation functionality against the build outputs Test auto-allocation functionality against the build outputs.
Note: auto-allocations only work for un-tracked stock!
""" """
allocations = self.build.getAutoAllocations(self.output_1) allocations = self.build.getAutoAllocations()
self.assertEqual(len(allocations), 1) self.assertEqual(len(allocations), 1)
self.build.autoAllocate(self.output_1) self.build.autoAllocate()
self.assertEqual(BuildItem.objects.count(), 1) self.assertEqual(BuildItem.objects.count(), 1)
# Check that one part has been fully allocated to the build output # Check that one un-tracked part has been fully allocated to the build
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1)) self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, None))
# But, the *other* build output has not been allocated against self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
def test_cancel(self): def test_cancel(self):
""" """
@ -243,9 +300,33 @@ class BuildTest(TestCase):
Test completion of a build output Test completion of a build output
""" """
self.allocate_stock(50, 50, 250, self.output_1) # Allocate non-tracked parts
self.allocate_stock(50, 50, 250, self.output_2) self.allocate_stock(
None,
{
self.stock_1_1: self.stock_1_1.quantity, # Allocate *all* stock from this item
self.stock_1_2: 10,
self.stock_2_1: 30
}
)
# Allocate tracked parts to output_1
self.allocate_stock(
self.output_1,
{
self.stock_3_1: 6
}
)
# Allocate tracked parts to output_2
self.allocate_stock(
self.output_2,
{
self.stock_3_1: 14
}
)
self.assertTrue(self.build.isFullyAllocated(None, verbose=True))
self.assertTrue(self.build.isFullyAllocated(self.output_1)) self.assertTrue(self.build.isFullyAllocated(self.output_1))
self.assertTrue(self.build.isFullyAllocated(self.output_2)) self.assertTrue(self.build.isFullyAllocated(self.output_2))
@ -265,19 +346,16 @@ class BuildTest(TestCase):
self.assertEqual(BuildItem.objects.count(), 0) self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created! # New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 4) self.assertEqual(StockItem.objects.count(), 7)
a = StockItem.objects.get(pk=self.stock_1_1.pk)
# This stock item has been depleted! # This stock item has been depleted!
with self.assertRaises(StockItem.DoesNotExist): with self.assertRaises(StockItem.DoesNotExist):
StockItem.objects.get(pk=self.stock_1_2.pk) StockItem.objects.get(pk=self.stock_1_1.pk)
c = StockItem.objects.get(pk=self.stock_2_1.pk) # This stock item has *not* been depleted
x = StockItem.objects.get(pk=self.stock_2_1.pk)
# Stock should have been subtracted from the original items self.assertEqual(x.quantity, 4970)
self.assertEqual(a.quantity, 900)
self.assertEqual(c.quantity, 4500)
# And 10 new stock items created for the build output # And 10 new stock items created for the build output
outputs = StockItem.objects.filter(build=self.build) outputs = StockItem.objects.filter(build=self.build)

View File

@ -15,7 +15,7 @@ from datetime import datetime, timedelta
from .models import Build from .models import Build
from stock.models import StockItem from stock.models import StockItem
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, StockStatus
class BuildTestSimple(TestCase): class BuildTestSimple(TestCase):
@ -335,6 +335,7 @@ class TestBuildViews(TestCase):
'confirm_incomplete': 1, 'confirm_incomplete': 1,
'location': 1, 'location': 1,
'output': self.output.pk, 'output': self.output.pk,
'stock_status': StockStatus.DAMAGED
}, },
HTTP_X_REQUESTED_WITH='XMLHttpRequest' HTTP_X_REQUESTED_WITH='XMLHttpRequest'
) )
@ -342,6 +343,7 @@ class TestBuildViews(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = json.loads(response.content) data = json.loads(response.content)
self.assertTrue(data['form_valid']) self.assertTrue(data['form_valid'])
# Now the build should be able to be completed # Now the build should be able to be completed

View File

@ -21,7 +21,6 @@ build_detail_urls = [
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'), url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
url(r'^children/', views.BuildDetail.as_view(template_name='build/build_children.html'), name='build-children'), url(r'^children/', views.BuildDetail.as_view(template_name='build/build_children.html'), name='build-children'),
url(r'^parts/', views.BuildDetail.as_view(template_name='build/parts.html'), name='build-parts'),
url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'), url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'),
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'), url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),

View File

@ -18,8 +18,8 @@ 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, extract_serial_numbers, normalize from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, StockStatus
class BuildIndex(InvenTreeRoleMixin, ListView): class BuildIndex(InvenTreeRoleMixin, ListView):
@ -98,16 +98,6 @@ class BuildAutoAllocate(AjaxUpdateView):
initials = super().get_initial() initials = super().get_initial()
# Pointing to a particular build output?
output = self.get_param('output')
if output:
try:
output = StockItem.objects.get(pk=output)
initials['output'] = output
except (ValueError, StockItem.DoesNotExist):
pass
return initials return initials
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
@ -119,18 +109,7 @@ class BuildAutoAllocate(AjaxUpdateView):
build = self.get_object() build = self.get_object()
form = self.get_form() context['allocations'] = build.getAutoAllocations()
output_id = form['output'].value()
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
output = None
if output:
context['output'] = output
context['allocations'] = build.getAutoAllocations(output)
context['build'] = build context['build'] = build
@ -140,18 +119,11 @@ class BuildAutoAllocate(AjaxUpdateView):
form = super().get_form() form = super().get_form()
if form['output'].value():
# Hide the 'output' field
form.fields['output'].widget = HiddenInput()
return form return form
def validate(self, build, form, **kwargs): def validate(self, build, form, **kwargs):
output = form.cleaned_data.get('output', None) pass
if not output:
form.add_error(None, _('Build output must be specified'))
def save(self, build, form, **kwargs): def save(self, build, form, **kwargs):
""" """
@ -159,9 +131,7 @@ class BuildAutoAllocate(AjaxUpdateView):
perform auto-allocations perform auto-allocations
""" """
output = form.cleaned_data.get('output', None) build.autoAllocate()
build.autoAllocate(output)
def get_data(self): def get_data(self):
return { return {
@ -242,7 +212,7 @@ class BuildOutputCreate(AjaxUpdateView):
# Calculate the required quantity # Calculate the required quantity
quantity = max(0, build.remaining - build.incomplete_count) quantity = max(0, build.remaining - build.incomplete_count)
initials['quantity'] = quantity initials['output_quantity'] = quantity
return initials return initials
@ -365,6 +335,12 @@ class BuildUnallocate(AjaxUpdateView):
output_id = request.POST.get('output_id', None) output_id = request.POST.get('output_id', None)
if output_id:
# If a "null" output is provided, we are trying to unallocate "untracked" stock
if isNull(output_id):
output = None
else:
try: try:
output = StockItem.objects.get(pk=output_id) output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist): except (ValueError, StockItem.DoesNotExist):
@ -383,9 +359,19 @@ class BuildUnallocate(AjaxUpdateView):
form.add_error('confirm', _('Confirm unallocation of build stock')) form.add_error('confirm', _('Confirm unallocation of build stock'))
form.add_error(None, _('Check the confirmation box')) form.add_error(None, _('Check the confirmation box'))
else: else:
build.unallocateStock(output=output, part=part)
valid = True valid = True
# Unallocate the entire build
if not output_id:
build.unallocateAll()
# Unallocate a single output
elif output:
build.unallocateOutput(output, part=part)
# Unallocate "untracked" parts
else:
build.unallocateUntracked(part=part)
data = { data = {
'form_valid': valid, 'form_valid': valid,
} }
@ -410,8 +396,8 @@ class BuildComplete(AjaxUpdateView):
def validate(self, build, form, **kwargs): def validate(self, build, form, **kwargs):
if not build.can_complete: if build.incomplete_count > 0:
form.add_error(None, _('Build order cannot be completed')) form.add_error(None, _('Build order cannot be completed - incomplete outputs remain'))
def save(self, build, form, **kwargs): def save(self, build, form, **kwargs):
""" """
@ -431,7 +417,7 @@ class BuildOutputComplete(AjaxUpdateView):
View to mark a particular build output as Complete. 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 - Assignes (tracked) allocated items from stock to the build output
- Deletes pending BuildItem objects - Deletes pending BuildItem objects
""" """
@ -463,11 +449,25 @@ class BuildOutputComplete(AjaxUpdateView):
return form return form
def validate(self, build, form, **kwargs): def validate(self, build, form, **kwargs):
"""
Custom validation steps for the BuildOutputComplete" form
"""
data = form.cleaned_data data = form.cleaned_data
output = data.get('output', None) output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
if int(stock_status) not in StockStatus.keys():
form.add_error('stock_status', _('Invalid stock status value selected'))
if output: if output:
quantity = data.get('quantity', None) quantity = data.get('quantity', None)
@ -559,12 +559,20 @@ class BuildOutputComplete(AjaxUpdateView):
location = data.get('location', None) location = data.get('location', None)
output = data.get('output', None) output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
# Complete the build output # Complete the build output
build.completeBuildOutput( build.completeBuildOutput(
output, output,
self.request.user, self.request.user,
location=location, location=location,
status=stock_status,
) )
def get_data(self): def get_data(self):
@ -632,10 +640,12 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView):
build = self.get_object() build = self.get_object()
part = build.part part = build.part
bom_items = part.bom_items bom_items = build.bom_items
context['part'] = part context['part'] = part
context['bom_items'] = bom_items context['bom_items'] = bom_items
context['has_tracked_bom_items'] = build.has_tracked_bom_items()
context['has_untracked_bom_items'] = build.has_untracked_bom_items()
context['BuildStatus'] = BuildStatus context['BuildStatus'] = BuildStatus
context['bom_price'] = build.part.get_price_info(build.quantity, buy=False) context['bom_price'] = build.part.get_price_info(build.quantity, buy=False)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -32,12 +32,17 @@ function newBuildOrder(options={}) {
} }
function makeBuildOutputActionButtons(output, buildInfo) { function makeBuildOutputActionButtons(output, buildInfo, lines) {
/* Generate action buttons for a build output. /* Generate action buttons for a build output.
*/ */
var buildId = buildInfo.pk; var buildId = buildInfo.pk;
var outputId = output.pk;
if (output) {
outputId = output.pk;
} else {
outputId = 'untracked';
}
var panel = `#allocation-panel-${outputId}`; var panel = `#allocation-panel-${outputId}`;
@ -50,11 +55,24 @@ function makeBuildOutputActionButtons(output, buildInfo) {
var html = `<div class='btn-group float-right' role='group'>`; var html = `<div class='btn-group float-right' role='group'>`;
// Add a button to "auto allocate" against the build // "Auto" allocation only works for untracked stock items
if (!output && lines > 0) {
html += makeIconButton( html += makeIconButton(
'fa-magic icon-blue', 'button-output-auto', outputId, 'fa-magic icon-blue', 'button-output-auto', outputId,
'{% trans "Auto-allocate stock items to this output" %}', '{% trans "Auto-allocate stock items to this output" %}',
); );
}
if (lines > 0) {
// Add a button to "cancel" the particular build output (unallocate)
html += makeIconButton(
'fa-minus-circle icon-red', 'button-output-unallocate', outputId,
'{% trans "Unallocate stock from build output" %}',
);
}
if (output) {
// Add a button to "complete" the particular build output // Add a button to "complete" the particular build output
html += makeIconButton( html += makeIconButton(
@ -65,20 +83,14 @@ function makeBuildOutputActionButtons(output, buildInfo) {
} }
); );
// Add a button to "cancel" the particular build output (unallocate)
html += makeIconButton(
'fa-minus-circle icon-red', 'button-output-unallocate', outputId,
'{% trans "Unallocate stock from build output" %}',
);
// Add a button to "delete" the particular build output // Add a button to "delete" the particular build output
html += makeIconButton( html += makeIconButton(
'fa-trash-alt icon-red', 'button-output-delete', outputId, 'fa-trash-alt icon-red', 'button-output-delete', outputId,
'{% trans "Delete build output" %}', '{% trans "Delete build output" %}',
); );
// Add a button to "destroy" the particular build output (mark as damaged, scrap) // TODO - Add a button to "destroy" the particular build output (mark as damaged, scrap)
// TODO }
html += '</div>'; html += '</div>';
@ -90,7 +102,6 @@ function makeBuildOutputActionButtons(output, buildInfo) {
launchModalForm(`/build/${buildId}/auto-allocate/`, launchModalForm(`/build/${buildId}/auto-allocate/`,
{ {
data: { data: {
output: outputId,
}, },
success: reloadTable, success: reloadTable,
} }
@ -98,11 +109,14 @@ function makeBuildOutputActionButtons(output, buildInfo) {
}); });
$(panel).find(`#button-output-complete-${outputId}`).click(function() { $(panel).find(`#button-output-complete-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm( launchModalForm(
`/build/${buildId}/complete-output/`, `/build/${buildId}/complete-output/`,
{ {
data: { data: {
output: outputId, output: pk,
}, },
reload: true, reload: true,
} }
@ -110,24 +124,30 @@ function makeBuildOutputActionButtons(output, buildInfo) {
}); });
$(panel).find(`#button-output-unallocate-${outputId}`).click(function() { $(panel).find(`#button-output-unallocate-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm( launchModalForm(
`/build/${buildId}/unallocate/`, `/build/${buildId}/unallocate/`,
{ {
success: reloadTable, success: reloadTable,
data: { data: {
output: outputId, output: pk,
} }
} }
); );
}); });
$(panel).find(`#button-output-delete-${outputId}`).click(function() { $(panel).find(`#button-output-delete-${outputId}`).click(function() {
var pk = $(this).attr('pk');
launchModalForm( launchModalForm(
`/build/${buildId}/delete-output/`, `/build/${buildId}/delete-output/`,
{ {
reload: true, reload: true,
data: { data: {
output: outputId output: pk
} }
} }
); );
@ -152,7 +172,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var outputId = null; var outputId = null;
if (output) {
outputId = output.pk; outputId = output.pk;
} else {
outputId = 'untracked';
}
var table = options.table; var table = options.table;
@ -160,6 +184,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
table = `#allocation-table-${outputId}`; table = `#allocation-table-${outputId}`;
} }
// If an "output" is specified, then only "trackable" parts are allocated
// Otherwise, only "untrackable" parts are allowed
var trackable = ! !output;
function reloadTable() { function reloadTable() {
// Reload the entire build allocation table // Reload the entire build allocation table
$(table).bootstrapTable('refresh'); $(table).bootstrapTable('refresh');
@ -168,7 +196,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
function requiredQuantity(row) { function requiredQuantity(row) {
// Return the requied quantity for a given row // Return the requied quantity for a given row
if (output) {
// "Tracked" parts are calculated against individual build outputs
return row.quantity * output.quantity; return row.quantity * output.quantity;
} else {
// "Untracked" parts are specified against the build itself
return row.quantity * buildInfo.quantity;
}
} }
function sumAllocations(row) { function sumAllocations(row) {
@ -300,6 +334,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
queryParams: { queryParams: {
part: partId, part: partId,
sub_part_detail: true, sub_part_detail: true,
sub_part_trackable: trackable,
}, },
formatNoMatches: function() { formatNoMatches: function() {
return '{% trans "No BOM items found" %}'; return '{% trans "No BOM items found" %}';
@ -310,11 +345,19 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
onLoadSuccess: function(tableData) { onLoadSuccess: function(tableData) {
// Once the BOM data are loaded, request allocation data for this build output // Once the BOM data are loaded, request allocation data for this build output
inventreeGet('/api/build/item/', var params = {
{
build: buildId, build: buildId,
output: outputId, }
},
if (output) {
params.sub_part_trackable = true;
params.output = outputId;
} else {
params.sub_part_trackable = false;
}
inventreeGet('/api/build/item/',
params,
{ {
success: function(data) { success: function(data) {
// Iterate through the returned data, and group by the part they point to // Iterate through the returned data, and group by the part they point to
@ -355,8 +398,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Calculate the total allocated quantity // Calculate the total allocated quantity
var allocatedQuantity = sumAllocations(tableRow); var allocatedQuantity = sumAllocations(tableRow);
var requiredQuantity = 0;
if (output) {
requiredQuantity = tableRow.quantity * output.quantity;
} else {
requiredQuantity = tableRow.quantity * buildInfo.quantity;
}
// Is this line item fully allocated? // Is this line item fully allocated?
if (allocatedQuantity >= (tableRow.quantity * output.quantity)) { if (allocatedQuantity >= requiredQuantity) {
allocatedLines += 1; allocatedLines += 1;
} }
@ -367,16 +418,21 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Update the total progress for this build output // Update the total progress for this build output
var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`)); var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`));
if (totalLines > 0) {
var progress = makeProgressBar( var progress = makeProgressBar(
allocatedLines, allocatedLines,
totalLines totalLines
); );
buildProgress.html(progress); buildProgress.html(progress);
} else {
buildProgress.html('');
}
// Update the available actions for this build output // Update the available actions for this build output
makeBuildOutputActionButtons(output, buildInfo); makeBuildOutputActionButtons(output, buildInfo, totalLines);
} }
} }
); );
@ -600,6 +656,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
}, },
] ]
}); });
// Initialize the action buttons
makeBuildOutputActionButtons(output, buildInfo, 0);
} }
@ -654,7 +713,7 @@ function loadBuildTable(table, options) {
field: 'reference', field: 'reference',
title: '{% trans "Build" %}', title: '{% trans "Build" %}',
sortable: true, sortable: true,
switchable: false, switchable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}"; var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}";
@ -675,6 +734,7 @@ function loadBuildTable(table, options) {
{ {
field: 'title', field: 'title',
title: '{% trans "Description" %}', title: '{% trans "Description" %}',
switchable: true,
}, },
{ {
field: 'part', field: 'part',
@ -725,7 +785,7 @@ function loadBuildTable(table, options) {
}, },
{ {
field: 'completion_date', field: 'completion_date',
title: '{% trans "Completed" %}', title: '{% trans "Completion Date" %}',
sortable: true, sortable: true,
}, },
], ],

View File

@ -9,8 +9,12 @@
{% block content %} {% block content %}
{% block header_panel %}
<div class='panel panel-default panel-inventree'> <div class='panel panel-default panel-inventree'>
{% block header_pre_content %}
{% endblock %}
<div class='row'> <div class='row'>
<div class='col-sm-6'> <div class='col-sm-6'>
<div class='media-left'> <div class='media-left'>
@ -30,8 +34,14 @@
{% endblock %} {% endblock %}
</div> </div>
</div> </div>
</div>
{% block header_post_content %}
{% endblock %}
</div>
{% endblock %}
{% block content_panels %}
<div class='panel panel-default panel-inventree'> <div class='panel panel-default panel-inventree'>
<div class='panel-heading'> <div class='panel-heading'>
<h4> <h4>
@ -41,12 +51,15 @@
</h4> </h4>
</div> </div>
{% block details_panel %}
<div class='panel-content'> <div class='panel-content'>
{% block details %} {% block details %}
<!-- Particular page detail views go here --> <!-- Particular page detail views go here -->
{% endblock %} {% endblock %}
</div> </div>
{% endblock %}
</div> </div>
{% endblock %}
{% endblock %} {% endblock %}